你說:
我要從git上面pull我的作業資料夾下來
好,我幫你一步一步整理一下從 Git 上把作業資料夾(repository)pull 下來的方法。這裡有兩種常見情況,看你是哪一種👇
也就是你的資料夾裡面已經有 .git。
在終端機(VS Code 的 Terminal 或系統的命令提示字元)裡:
bashcd 路徑/到/你的/作業資料夾
git pull
這樣會:
從遠端(remote)抓最新的變更
自動合併(merge)到你的本地版本
如果你要確認遠端是哪個 repo,可以用:
bashgit remote -v
那就要先「下載」整個 repository:
bashgit clone https://github.com/你的帳號/作業repo名稱.git
例如:
bashgit clone https://github.com/annie-fu/data-science-hw3.git
這樣會在目前的資料夾底下建立一個新資料夾(repo 名稱),裡面就是所有內容。
如果是 GitHub Classroom 給的作業,網址通常長這樣:
rubyhttps://github.com/ntu-psych-data-class/hw3-annie-fu
進到資料夾:
bashcd hw3-annie-fu
然後查看目前的狀態:
bashgit status
如果顯示:
vbnetOn branch main
Your branch is up to date with 'origin/main'.
表示你成功 pull 下來了。
要不要我幫你看一下具體要用哪個指令?
你可以貼上:
你想要下載的 repo 網址
你現在的資料夾狀態(有沒有 .git)
沒錯 👍 你可以直接在 VS Code 的 Terminal 裡開啟資料夾。步驟如下:
假設你已經在 Terminal 裡面(例如 PowerShell、cmd、或 VS Code 內建的 Terminal),
輸入以下指令即可打開整個資料夾:
bashcode .
意思是「用 VS Code 打開當前目錄(.)」。
如果你還沒 cd 到那個資料夾,也可以指定路徑:
bashcode 路徑/到/資料夾
例如:
bashcode ~/Documents/hw3-annie-fu
或是 Windows 上:
bashcode "C:\Users\Annie_Fu\Documents\hw3-annie-fu"
那表示你需要先把 VS Code 的命令列工具加入系統路徑。
解法:
打開 VS Code
按下 Ctrl + Shift + P(或 Cmd + Shift + P 在 mac)
搜尋並執行:
bashShell Command: Install 'code' command in PATH
關掉 Terminal,再重新開啟一個新的
然後再試一次:
bashcode .
要不要我幫你看一下你的 terminal 路徑目前在哪(用 pwd 或 cd 命令)?
我可以幫你確認該用 cd 到哪再執行 code .。
我沒辦法直接開啟那個 GitHub Docs 網址,但我可以直接告訴你怎麼在已經存在的 public repository 上建立 GitHub Pages 網頁。你不用重開一個新的空 repo,直接用你現有的那個就可以。👇
只有 public repo 才能免費開啟 GitHub Pages。
在你的 repo 頁面右上角 → Settings → General → Danger Zone
確認 “Visibility: Public”。
在你的 repo 裡需要有一個網頁檔:
最簡單是 index.html
或者你用 README.md(GitHub 會自動轉成首頁樣式)
如果沒有,請新增:
diffindex.html
內容例如:
html<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>My GitHub Page</title>
</head>
<body>
<h1>Hello from Annie!</h1>
<p>This is my GitHub Pages site.</p>
</body>
</html>
進到你的 repo → 點上方的 Settings
左側選單點 Pages
在 “Build and deployment” 下:
Source:選擇 Deploy from a branch
Branch:選擇 main(或你主要的分支)
Folder:選擇 / (root) 或 /docs(看你想放哪)
按下 Save
GitHub 會自動生成網址,格式是:
cpphttps://你的GitHub使用者名稱.github.io/你的repo名稱/
比如:
arduinohttps://annie-fu.github.io/data-science-hw3/
等一兩分鐘刷新就能看到網站。
/docs 資料夾你也可以把所有 HTML、CSS、JS 檔放在 docs/ 裡:
pgsqldocs/
index.html
然後在 Pages settings 裡改成:
yamlBranch: main | Folder: /docs
要不要我幫你看一下你的 repo 結構(例如你現在有哪些檔案),我可以幫你判斷要選 root 還是 /docs?
好,我們來一步步排查 👇
GitHub Pages 不會立即出現網址,有幾個常見原因(尤其是舊 repo 改開啟 Pages 時)。我們可以這樣檢查:
請到你的 repo 頁面右上角:
nginxSettings → Pages
然後檢查這兩點:
Build and deployment → Source:✅ Deploy from a branch
Branch:✅ main(或 master)
Folder:✅ / (root) 或 /docs
狀態列底下應該會顯示:
csharpYour site is live at https://<username>.github.io/<repo-name>/
若還沒出現,表示還在部署。
GitHub Pages 需要一點時間生成靜態網站。
部署完成後,會自動產生網址在頁面上方,或寄信通知你。
你可以手動試著打開:
php-templatehttps://<你的GitHub帳號>.github.io/<repo名稱>/
例如:
arduinohttps://annie-fu.github.io/data-science-hw3/
看看是否可以開啟。
index.html如果沒有首頁檔案(或 docs/ 裡沒有),GitHub Pages 雖然會部署成功,但會是一個空白頁。
確認:
若你設定 root(/),首頁檔案應該在根目錄:index.html
若你設定 /docs,則應該在:docs/index.html
到:
sqlSettings → Pages → Build and deployment → “Check your GitHub Actions workflows”
或到:
nginxActions → Pages build and deployment
看看是否顯示綠色勾勾 ✅(成功),
若是紅色 ❌,點進去可以看到錯誤訊息。
有時 repo 是空的或沒有 HTML 檔,GitHub Pages 會自動取消部署。
請確認至少有一個 HTML 或 Markdown 檔。
你可以把以下資訊貼給我(我幫你看):
你目前 repo 的網址(例如 https://github.com/annie-fu/xxx)
你在 Settings → Pages 裡看到的選項設定截圖或文字
我可以幫你確定是部署延遲、設定錯誤、還是缺檔案。
很好,從你的截圖看起來設定是正確的 ✅:
Source: Deploy from a branch
Branch: main
Folder: /(root)
這代表 GitHub Pages 已經被啟用,接下來只差一個小步就能看到網址。以下幾個情況會造成你暫時看不到「Your site is live at …」那一行:
index.html 或 README.md)請先確認主分支(main)下是否有一個首頁檔案,例如:
diffindex.html
或至少:
README.md
若沒有,請新增一個最簡單的:
html<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Hello</title>
</head>
<body>
<h1>GitHub Pages 測試成功!</h1>
</body>
</html>
→ 儲存並 push 回 GitHub。
GitHub Pages 通常要等 1–5 分鐘才會生成。
你可以前往 repo 的 Actions 分頁,看有沒有出現一個:
nginxPages build and deployment
的工作流程(Workflow)。
若是綠色勾勾 ✅,表示成功;若是紅叉 ❌,表示出錯。
在設定完成後,網址格式一定是:
php-templatehttps://<你的帳號>.github.io/<repo名稱>/
例如:
pgsqlhttps://ntu-info.github.io/your-repo-name/
可以直接打開這個看看。
有時候 GitHub 的 UI 不即時更新。你可以:
切換 Folder → /docs,按 Save
再改回 / (root),按 Save
這樣會強制重新觸發一次部署。
你要不要貼一下這個 repo 的 GitHub 網址?
我可以直接幫你判斷你應該加 index.html 還是調整設定。
我幫你完整讀過了這份 index.html,以下是整理出的說明:
這是一個簡單的前端測試頁面,用來從伺服器端(https://mil.psy.ntu.edu.tw:5000/terms)抓資料並顯示在畫面上。
具體來說,它會:
一載入頁面就發出 fetch 請求;
也可以透過「Fetch again」按鈕手動重新請求;
把伺服器回傳的內容(不論是 JSON 或文字)顯示在頁面下方的 <pre> 區塊中;
若發生錯誤(例如 CORS 問題、網路錯誤、伺服器沒回應),會顯示錯誤訊息與常見原因。
整體流程如下:
html<h1>Fetch https://mil.psy.ntu.edu.tw:5000/terms</h1>
<button id="btn">Fetch again</button>
<pre id="out">Loading…</pre>
<h1>:標題。
<button>:讓使用者手動觸發重新抓資料。
<pre>:顯示伺服器回傳的內容(保留換行與縮排)。
主要邏輯在 <script> 裡的 fetchTerms() 函式:
jsasync function fetchTerms() {
const out = document.getElementById('out');
out.textContent = 'Requesting…';
try {
const resp = await fetch('https://mil.psy.ntu.edu.tw:5000/terms');
let info = `HTTP ${resp.status} ${resp.statusText}\n`;
const ct = resp.headers.get('content-type') || '';
先取得 <pre> 元素並顯示「Requesting…」。
使用 fetch() 對指定網址發出 HTTP 請求。
收到回應後,先記錄 HTTP 狀態碼與狀態文字(例如 HTTP 200 OK)。
讀取 response header 裡的 content-type。
接著判斷回應型態:
jsif (!resp.ok) {
const text = await resp.text();
out.textContent = info + '\n' + text;
return;
}
若回應不是成功(例如 404、500),就把錯誤內容直接顯示出來。
jsif (ct.includes('application/json')) {
const data = await resp.json();
out.textContent = info + '\n' + JSON.stringify(data, null, 2);
} else {
const text = await resp.text();
out.textContent = info + '\n' + text;
}
若 content-type 包含 application/json,就把 JSON 格式化後顯示。
否則當作純文字顯示。
錯誤處理部分:
js} catch (err) {
document.getElementById('out').textContent =
'Error: ' + (err && err.message ? err.message : String(err)) +
'\n\nCommon causes:\n1) CORS not allowed by the server\n2) Opening this file via file:// instead of a local server\n3) Network/server issues';
}
捕捉到錯誤(例如跨來源請求被擋),會在頁面顯示錯誤原因提示。
最後兩行:
jsdocument.getElementById('btn').addEventListener('click', fetchTerms);
fetchTerms();
第一行:綁定按鈕事件,點下按鈕會重新抓資料。
第二行:載入網頁時立即呼叫一次 fetchTerms(),自動顯示結果。
typescript載入頁面
↓
呼叫 fetchTerms()
↓
顯示 "Requesting..."
↓
fetch() → https://mil.psy.ntu.edu.tw:5000/terms
↓
成功?───────→ 否 → 顯示 HTTP 錯誤內容
│
↓
根據 content-type 顯示 JSON 或文字
↓
若錯誤 → 顯示 Error 與常見原因
功能目標:測試連線到伺服器 /terms 的 API 是否可正常回傳資料。
邏輯重點:使用 fetch() 非同步請求 + 基於回應型態自動解析並顯示結果 + 錯誤處理。
是否要我幫你把它改成可以部署在 GitHub Pages 上、避免被 CORS 擋下來的版本(例如用 proxy 或 mock JSON)?
非常好,你的問題關鍵在於「目前的 index.html 只是個測試 fetch,離作業目標還差一段距離」。我幫你整理一下作業要求與你目前的進度對照,並說明你接下來應該做什麼👇
Sprint Goal:
Build an AJAX-based, pretty, & publicly available frontend using Tailwind/Bootstrap (among other JS libraries) for Tren’s backend @ https://mil.psy.ntu.edu.tw:5000
串接三個 API endpoints:
/terms:列出所有可用的 terms
/terms/<t1>:查詢與某個 term 相關的其他 terms
/query/<q_string>/studies:搜尋與特定查詢字串(query string)相關的 studies
使用 AJAX(即 fetch / async JS)非同步地載入資料,不要整頁重整。
用前端框架美化頁面(Bootstrap 或 Tailwind),呈現結果要「漂亮」、「可互動」。
部署在 GitHub Pages 上,讓它能公開瀏覽。
目前的程式只做到最基本的:
✅ 成功 fetch /terms
❌ 沒有 UI 美化(Bootstrap/Tailwind)
❌ 沒有支援 /terms/<t1> 或 /query/<q_string>/studies
❌ 沒有互動式查詢(例如輸入框 + 顯示結果)
❌ 沒有多區塊頁面結構(navbar、輸出區、loading 狀態等)
一個合理的完成版本會包含:
html<div class="container mx-auto p-6">
<h1 class="text-2xl font-bold mb-4">Tren API Frontend</h1>
<!-- 查詢區 -->
<div class="mb-4">
<input id="termInput" class="border p-2 rounded w-1/2" placeholder="Enter a term">
<button id="fetchTerms" class="bg-blue-500 text-white px-4 py-2 rounded">Search Related Terms</button>
</div>
<div class="mb-4">
<input id="queryInput" class="border p-2 rounded w-1/2" placeholder="Enter query string">
<button id="fetchStudies" class="bg-green-500 text-white px-4 py-2 rounded">Search Studies</button>
</div>
<!-- 結果顯示區 -->
<pre id="output" class="bg-gray-100 p-4 rounded h-80 overflow-auto"></pre>
</div>
jsasync function getAllTerms() {
const resp = await fetch("https://mil.psy.ntu.edu.tw:5000/terms");
const data = await resp.json();
output.textContent = JSON.stringify(data, null, 2);
}
async function getRelatedTerms() {
const term = document.getElementById("termInput").value;
const resp = await fetch(`https://mil.psy.ntu.edu.tw:5000/terms/${term}`);
const data = await resp.json();
output.textContent = JSON.stringify(data, null, 2);
}
async function getStudies() {
const q = document.getElementById("queryInput").value;
const resp = await fetch(`https://mil.psy.ntu.edu.tw:5000/query/${q}/studies`);
const data = await resp.json();
output.textContent = JSON.stringify(data, null, 2);
}
然後在頁尾掛上事件:
jsdocument.getElementById("fetchTerms").addEventListener("click", getRelatedTerms);
document.getElementById("fetchStudies").addEventListener("click", getStudies);
window.onload = getAllTerms;
| 步驟 | 任務 | 結果 |
|---|---|---|
| 1 | 加入 Tailwind CDN 或 Bootstrap CDN | 頁面有美觀基底 |
| 2 | 新增輸入欄位與按鈕 | 可以輸入 term 或 query |
| 3 | 寫三個 fetch 函式分別呼叫 /terms、/terms/<t1>、/query/<q_string>/studies | 三種查詢功能可運作 |
| 4 | 用 <pre> 或 <table> 顯示結果 | 結果可閱讀 |
| 5 | push 到 GitHub main 分支 → Settings → Pages 開啟 | 網頁能線上預覽 |
我可以幫你直接把目前的 index.html 改成符合上述要求的版本(含 Tailwind、美化、三個查詢功能),你要嗎?
非常好的問題,這是你現在卡住的關鍵點 👇
你的作業明確說:
Build an AJAX-based frontend for Tren’s backend @ https://mil.psy.ntu.edu.tw:5000
這句話其實代表:
➡️ 你不需要自己架後端或 dataset。
➡️ 你要做的只是連線到老師提供的那個後端伺服器(https://mil.psy.ntu.edu.tw:5000)
來取資料,然後用 HTML+JS 顯示在網頁上。
你的 dataset 已經由老師的伺服器提供。
你的 app port (端口) 也是伺服器那邊(5000),你不需要在前端設定 port。
你只要在 fetch() 時用完整網址就行,例如:
jsfetch("https://mil.psy.ntu.edu.tw:5000/terms")
這樣就會呼叫 Tren 的伺服器(而不是你的電腦)。
scssindex.html ← 主要網頁 (HTML + JS + CSS)
(可選) style.css ← 額外樣式
(可選) script.js ← 若想把 JS 拆開
不需要:
不需要 Flask / Express / Node.js
不需要資料庫 / dataset 檔案
不需要設定 port
你目前的測試網頁若是「直接雙擊開啟」(file://index.html),會被瀏覽器擋掉(CORS)。
因為它要從 file:// 去請求 https://mil.psy.ntu.edu.tw:5000。
✅ 解法:
你可以用以下其中一種方式:
因為 GitHub Pages 也是 HTTPS,CORS 允許它安全連線。
在你本機開啟一個本地 HTTP server:
bash# Python 3
python -m http.server 8000
然後在瀏覽器打開:
arduinohttp://localhost:8000
這樣就能正常 fetch 了。
| 類別 | 負責方 | 你需要處理嗎? |
|---|---|---|
| Backend API | 老師(mil.psy.ntu.edu.tw:5000) | ❌ 不用 |
| Dataset | 後端提供 | ❌ 不用 |
| Frontend (AJAX + UI) | 你 | ✅ 要做 |
| Port / Server | 已由後端決定 | ❌ 不用改 |
所以你接下來的任務只有:
用 fetch("https://mil.psy.ntu.edu.tw:5000/...") 呼叫 API。
美化前端 UI(Tailwind / Bootstrap)。
部署在 GitHub Pages,確保能成功呼叫後端。
要不要我幫你把你的 index.html 改成一個完整版本(含三個 API 查詢功能、Tailwind、美化排版),讓你可以直接部署?
超棒的問題,這代表你已經理解 fetch 是「呼叫伺服器端 API」的動作,但現在卡在「結果拿到之後要放哪、怎麼顯示」這一層。
我幫你拆解一下你現在的狀況與應該怎麼做👇
/terms回傳「所有可用的詞彙」
➡️ 通常是一個 JSON 陣列,例如:
json["pain", "empathy", "moral", "punishment"]
/terms/<t1>例如:
bashhttps://mil.psy.ntu.edu.tw:5000/terms/emotion
會回傳與 “emotion” 有關的其他 terms,比如:
json{
"emotion": ["feeling", "affect", "sadness", "anger"]
}
/query/<q_string>/studies例如:
bashhttps://mil.psy.ntu.edu.tw:5000/query/emotion/studies
會回傳與「emotion」相關的研究(studies),格式可能像這樣:
json[
{"id": 1, "title": "Neural basis of moral emotions", "authors": ["Smith", "Lee"], "year": 2022},
{"id": 2, "title": "Empathy and social pain", "authors": ["Chen"], "year": 2020}
]
這就是你說的「包含 studies 的 dataset」。
這裡的「寫」不是寫進伺服器,而是「顯示在你的前端網頁裡」。
例如你 HTML 裡有這個區塊:
html<pre id="output"></pre>
你只要在 JavaScript 裡這樣做:
jsconst output = document.getElementById("output");
const resp = await fetch("https://mil.psy.ntu.edu.tw:5000/query/emotion/studies");
const data = await resp.json();
output.textContent = JSON.stringify(data, null, 2);
網頁上就會直接顯示整個 JSON 結果。
這是一個完整、你可以直接放到 GitHub Pages 的版本雛形:
html<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<script src="https://cdn.tailwindcss.com"></script>
<title>Tren API Frontend</title>
</head>
<body class="bg-gray-50 p-8">
<h1 class="text-3xl font-bold mb-6">Tren API Frontend</h1>
<div class="space-y-4">
<!-- Get all terms -->
<button id="getAll" class="bg-blue-500 text-white px-4 py-2 rounded">Show all terms</button>
<!-- Related terms -->
<div>
<input id="termInput" class="border p-2 rounded" placeholder="Enter a term (e.g. emotion)">
<button id="getRelated" class="bg-green-500 text-white px-4 py-2 rounded">Get related terms</button>
</div>
<!-- Query studies -->
<div>
<input id="queryInput" class="border p-2 rounded" placeholder="Enter a query (e.g. empathy)">
<button id="getStudies" class="bg-purple-500 text-white px-4 py-2 rounded">Get related studies</button>
</div>
</div>
<h2 class="text-xl font-semibold mt-8">Result:</h2>
<pre id="output" class="bg-white border p-4 rounded mt-2 h-96 overflow-auto">No data yet.</pre>
<script>
const output = document.getElementById("output");
async function fetchJSON(url) {
output.textContent = "Fetching " + url + " ...";
const resp = await fetch(url);
const data = await resp.json();
output.textContent = JSON.stringify(data, null, 2);
}
document.getElementById("getAll").addEventListener("click", () => {
fetchJSON("https://mil.psy.ntu.edu.tw:5000/terms");
});
document.getElementById("getRelated").addEventListener("click", () => {
const term = document.getElementById("termInput").value;
fetchJSON(`https://mil.psy.ntu.edu.tw:5000/terms/${term}`);
});
document.getElementById("getStudies").addEventListener("click", () => {
const query = document.getElementById("queryInput").value;
fetchJSON(`https://mil.psy.ntu.edu.tw:5000/query/${query}/studies`);
});
</script>
</body>
</html>
| 你在問的東西 | 實際上要做的事 |
|---|---|
| dataset | 老師的後端已提供 |
| app 端口 | 後端 port 5000,前端不用設定 |
| fetch 出來的資料 | 顯示在 HTML 的某個元素(例如 <pre>、<div>) |
| 要在哪「寫」 | 直接在你的 JS 函式裡用 DOM 操作顯示資料 |
| 是否需要自己啟後端 | ❌ 不需要,你只做前端 AJAX |
如果你願意,我可以幫你把你現有的 index.html 改成可直接使用上面三個 API、含 UI 美化、部署即用的版本(你只要上傳 GitHub Pages 就能跑)。要嗎?
可以!先回答兩件事:
最快的三種方式(選一種就好):
直接用瀏覽器開
打開:
https://mil.psy.ntu.edu.tw:5000/terms、
https://mil.psy.ntu.edu.tw:5000/terms/emotion(把 emotion 換成你想測的 term)、
https://mil.psy.ntu.edu.tw:5000/query/emotion/studies
就能看到原始 JSON/文字回應。
用 Terminal 測
bashcurl -i https://mil.psy.ntu.edu.tw:5000/terms
在你現有的頁面加 debug(最貼近實作)
在 fetchTerms() 裡解析 JSON 後加上:
jsconsole.log('RAW JSON from backend:', data);
開開瀏覽器的 DevTools (F12) 看 Console。
你的檔案其實已經會把回傳內容印到
<pre id="out">裡了(成功時會JSON.stringify(data, null, 2)),而且會先顯示HTTP 200 OK等狀態行,這些都在fetchTerms()裡面寫好了。index
就在你檔案裡的 fetchTerms() 函式中。流程是:
先 fetch('https://mil.psy.ntu.edu.tw:5000/terms')
讀 resp.headers.get('content-type')
如果是 JSON:const data = await resp.json();
顯示: out.textContent = info + '\n' + JSON.stringify(data, null, 2);
也就是把回來的資料轉成漂亮縮排的 JSON,塞到 <pre id="out">。index
把你現在「直接 JSON.stringify」的那段,換成以下邏輯:如果拿到的是 array,就排成 <ul>;如果是物件(像 { "emotion": ["sad","anger"] }),就把 key 當小標、value 當清單。
把 if (ct.includes('application/json')) { ... } 這一段替換為(貼上即可):
jsif (ct.includes('application/json')) {
const data = await resp.json();
// 1) 先把 HTTP 狀態放上去
let html = `<div>${info.replace(/\n/g, '<br>')}</div>`;
// 2) 依資料型態渲染
const toList = (arr) =>
`<ul style="margin:0;padding-left:1.25rem">${arr.map(x => `<li>${x}</li>`).join('')}</ul>`;
if (Array.isArray(data)) {
// /terms 可能回陣列
html += toList(data);
} else if (data && typeof data === 'object') {
// /terms/<t1> 可能回 { t1: [ ... ] } 或更複雜物件
html += Object.entries(data).map(([k, v]) => {
if (Array.isArray(v)) {
return `<h3 style="margin:.5rem 0 0">${k}</h3>${toList(v)}`;
} else if (v && typeof v === 'object') {
// 物件裡面還有物件 -> 就當 JSON 顯示
return `<h3 style="margin:.5rem 0 0">${k}</h3><pre>${JSON.stringify(v, null, 2)}</pre>`;
} else {
return `<h3 style="margin:.5rem 0 0">${k}</h3><div>${String(v)}</div>`;
}
}).join('');
} else {
// 其他型態,保底印 JSON
html += `<pre>${JSON.stringify(data, null, 2)}</pre>`;
}
// 3) 寫回畫面
document.getElementById('out').innerHTML = html;
} else {
const text = await resp.text();
out.textContent = info + '\n' + text;
}
這段就是把「顯示邏輯」放在你原本
fetchTerms()裡「解析 JSON」的那個位置,用innerHTML產清單;其他結構(按鈕、#out區塊、錯誤處理)維持不變。index
所有 terms:/terms
某個 term 的相關 terms:/terms/<t1>(把 <t1> 換成你的字)
查 studies:/query/<q>/studies(把 <q> 換成查詢字串)
如果你想,我可以把你現有的 index.html 直接改成「三個按鈕/輸入框 + 清單渲染 + 錯誤訊息」的一頁式完成版,貼回給你覆蓋就能跑。
完全不用開三個 HTML ✋。
這三個端點(/terms、/terms/<t1>、/query/<q_string>/studies)雖然是不同功能,但你可以都放在同一個前端頁面裡,只要在 JavaScript 裡用不同按鈕或輸入框去呼叫它們就行。
例如:
html<h1>Tren API Demo</h1>
<!-- 1. 查全部 terms -->
<button id="btnAll">Get all terms</button>
<!-- 2. 查相關 terms -->
<input id="termInput" placeholder="Enter a term">
<button id="btnTerm">Get related terms</button>
<!-- 3. 查 studies -->
<input id="queryInput" placeholder="Enter query string">
<button id="btnStudy">Search studies</button>
<!-- 顯示結果 -->
<pre id="out">Waiting…</pre>
對應的 JS:
jsconst out = document.getElementById('out');
async function fetchData(url) {
out.textContent = 'Fetching ' + url + '...';
const resp = await fetch(url);
const data = await resp.json();
out.textContent = JSON.stringify(data, null, 2);
}
// 三個按鈕對應三個 endpoint
document.getElementById('btnAll').onclick = () =>
fetchData('https://mil.psy.ntu.edu.tw:5000/terms');
document.getElementById('btnTerm').onclick = () => {
const term = document.getElementById('termInput').value;
fetchData(`https://mil.psy.ntu.edu.tw:5000/terms/${term}`);
};
document.getElementById('btnStudy').onclick = () => {
const q = document.getElementById('queryInput').value;
fetchData(`https://mil.psy.ntu.edu.tw:5000/query/${q}/studies`);
};
這是一個「單頁 AJAX 應用」(Single Page Application, SPA)概念:
透過 JavaScript 動態更新頁面內容,而不是跳轉不同 HTML。
你可以在同一頁裡用不同按鈕或 tab 切換要看的資料。
作業要求的 AJAX-based frontend 就是要你練這種非同步互動。
可以,但不是必要。那就:
terms.html → 顯示所有 terms
related.html → 查 related terms
studies.html → 查 studies
每頁都 fetch 不同端點。不過這樣會多餘、重複程式,老師希望你用 JS 動態更新。
簡單講:
你現在需要的不是「三份 HTML」,而是「一份 HTML + 幾個按鈕 + 幾個 fetch()」。
要不要我幫你把這一頁整合成漂亮版(用 Tailwind + 三個按鈕 + 顯示區)給你直接放 GitHub Pages?
非常好的問題——「AJAX」其實是整個作業概念的核心。這裡用你目前的作業脈絡來講會最清楚 👇
AJAX 全名是 Asynchronous JavaScript and XML,
意思是「非同步地,用 JavaScript 跟伺服器交換資料,不需要整個頁面重新載入」。
但實際上現在已經不再只用 XML,而是用 JSON 為主。
所以你可以把它理解成:
AJAX = 用 JavaScript 向伺服器要資料(或送資料),然後在頁面上動態更新。
| 傳統方式(例如純 HTML 表單) | AJAX 方式 |
|---|---|
| 每次要新資料都要重新載入整頁 | 不用重載,直接更新畫面的一部分 |
| 伺服器回傳整個新頁面 | 伺服器只回資料(通常是 JSON) |
| 使用者會看到頁面閃一下、重新整理 | 使用者感覺資料「即時更新」 |
| 常見於舊式網站 | 幾乎所有現代網站都用 AJAX |
jsconst resp = await fetch('https://mil.psy.ntu.edu.tw:5000/terms');
const data = await resp.json();
document.getElementById('out').textContent = JSON.stringify(data, null, 2);
這三行實際上就是:
用 JavaScript 非同步請求 Tren 的伺服器(這就是「AJAX request」);
伺服器回傳 JSON 資料;
你把這些資料顯示在頁面上,而整個頁面不會重新載入。
因為這樣你的前端可以:
即時顯示不同查詢結果;
可以讓使用者輸入 term、按按鈕、馬上看到資料;
也能在同一個頁面裡串三個不同的端點 /terms、/terms/<t1>、/query/<q>/studies;
而不用三個 HTML。
| 概念 | 意思 | 你作業裡的例子 |
|---|---|---|
| A – Asynchronous | 非同步,不會卡住整頁 | await fetch(...) |
| J – JavaScript | 用 JS 控制頁面 | fetchTerms() 裡的邏輯 |
| A – And | 就是「然後」XD | — |
| X – XML (現在是 JSON) | 資料格式 | resp.json() |
| → AJAX | 用 JS 非同步拿資料、更新網頁 | 你整個前端作業要做的事 |
簡單一句話:
AJAX = 用 fetch() 從伺服器拿資料回來,動態更新畫面,不重新載入整頁。
如果你願意,我可以幫你在你目前的 index.html 加一點註解(用中文解釋哪裡是 AJAX、哪裡是 DOM 更新、哪裡是錯誤處理),讓你讀程式時直接看到每行的作用。要嗎?
我已經在你的 index.html 裡加上中文註解,逐行說明:
哪裡是 AJAX 呼叫(fetch)
哪裡在 處理伺服器回應
哪裡是 錯誤處理與狀態提示
哪裡是 DOM 更新(把資料顯示到頁面上)
你要我接著幫你加上三個端點(/terms、/terms/<t1>、/query/<q>/studies)的互動版本嗎?我可以在這個有註解的版本上直接擴充。
這個問題問得非常好,而且這正是 AJAX 的神奇之處——
你會發現它「直接能跑」,是因為它完全依賴 瀏覽器本身 的功能,而不是需要另外裝的工具。
我幫你拆開講 👇
AJAX 是由三個東西組成的:
| 元件 | 說明 | 你在哪裡用了 |
|---|---|---|
| HTML | 網頁結構(用來放按鈕、輸出區) | <h1>、<pre>、<button> |
| JavaScript | 控制邏輯、發出請求 | fetch()、addEventListener() |
| Browser(瀏覽器) | 執行環境(負責幫你跑 JS、發 HTTP 請求) | Chrome、Edge、Firefox 等 |
瀏覽器天生就有:
fetch()
Promise / async / await
DOM API (document.getElementById)
所以你只要打開一個 .html 檔案,瀏覽器就能直接執行整個 AJAX 流程。
完全不需要安裝 Node.js、VS Code、伺服器或任何 library。
目前你的 index.html 只是:
從 老師的伺服器(mil.psy.ntu.edu.tw:5000)抓資料;
然後在 自己的瀏覽器 顯示結果。
也就是說:
你的電腦扮演「使用者」;
老師的伺服器扮演「資料提供者」;
你沒有在自己電腦開任何「後端伺服器」。
這種「純前端呼叫遠端 API」的情境就是 AJAX 的典型應用。
VS Code 的角色是:
幫你「寫」程式;
幫你語法高亮、補全;
可以開 terminal。
但實際上「跑 AJAX」的是瀏覽器。
即使你在 VS Code 打開 .html,你仍是用 Chrome/Edge 去看結果。
(VS Code 自己不會執行 HTML。)
你會發現「有時候可以跑、有時候不行」是因為:
如果你用 file:// 開啟,瀏覽器安全限制會擋掉跨域(CORS);
如果你部署到 GitHub Pages(HTTPS domain),就能正常連老師的伺服器。
這不是語法問題,而是安全性機制。
| 你看到的現象 | 原因 |
|---|---|
| 不用裝 VS Code 就能執行 | 因為 AJAX 在瀏覽器裡原生支援 |
| fetch() 能直接跑 | fetch 是瀏覽器內建 API |
| 顯示結果在頁面上 | JavaScript 操作 DOM |
| 不需要本地伺服器 | 因為你只在呼叫遠端(老師提供的)後端 |
| 可能遇到 CORS 限制 | 瀏覽器的安全政策,不是語法錯誤 |
要我幫你畫一張簡單的「AJAX 請求流程圖」嗎?會把 browser、API、server 之間的資料流畫出來,讓你一眼看懂為什麼這整個東西能跑起來。
我沒辦法直接開那兩個 ithelp 的連結,不過我可以幫你根據內容主題(它們在講 AJAX 與 Axios)簡單解釋它們在說什麼,然後用一張圖幫你整理整個 AJAX 的運作流程 👇
scss[使用者點按鈕]
↓
[瀏覽器裡的 JavaScript]
│ (呼叫 fetch / axios)
↓
[送出 HTTP Request → 後端伺服器]
│
↓
[伺服器運算 / 查資料 / 回傳 JSON]
│
↓
[瀏覽器收到 Response]
│
↓
[JavaScript 解析資料 → 更新畫面 (DOM)]
→ 整個過程中「頁面不會重新載入」,這就是 AJAX。
這篇主要介紹了:
傳統瀏覽器請求流程(整頁 reload)
AJAX 的出現讓網頁能「非同步」請求資料
早期用 XMLHttpRequest 物件來做,語法比較老
現代改用 fetch(),更簡潔
jsfetch('/api/data')
.then(res => res.json())
.then(data => console.log(data))
重點是:「AJAX 不是一個框架,也不是一個語言,是一種做法(Asynchronous JavaScript + XML)。」
這篇講的是——雖然 fetch() 已經很好用,但它仍有幾個小缺點:
fetch() 在伺服器錯誤時(HTTP 404/500)不會自動丟錯;
要處理 JSON、設定 header、加上 timeout 比較麻煩;
fetch 在舊瀏覽器支援度略低。
Axios 就是為了讓 AJAX 更方便的第三方函式庫:
用法更簡單、風格像 jQuery 時代的 $.ajax;
會自動處理錯誤與 JSON;
可以統一設定 baseURL、headers;
有攔截器(interceptors)可統一處理 request/response。
範例:
jsaxios.get('https://mil.psy.ntu.edu.tw:5000/terms')
.then(res => console.log(res.data))
.catch(err => console.error(err));
所以:
fetch()是原生語法;
axios是更好用的包裝,功能更完整。
| 功能 | fetch() | axios |
|---|---|---|
| 內建 | ✅(原生支援) | ❌(需額外引入 CDN 或 npm) |
| 回傳格式 | Promise,需手動 .json() | 自動轉 JSON |
| 錯誤處理 | HTTP 錯誤需自己判斷 | 自動丟錯 |
| 設定 header | 稍微繁瑣 | 簡單統一 |
| 攔截器(interceptors) | ❌ | ✅ |
| 適合誰 | 學 AJAX 原理、簡單專案 | 複雜專案、多 API 管理 |
✅ 在你的作業中不用 Axios,fetch 就夠了。
因為:
你只要打三個 API;
GitHub Pages 可直接跑;
不需要額外載入 library。
要不要我幫你畫一個比較「圖像化」的版本(像流程框圖 + fetch/axios 對比)?
我可以用箭頭、框框畫出「使用者 → JS → 後端 → JSON → 更新頁面」那種視覺圖。
這裡是一張簡單的 AJAX 流程圖,幫你可視化整個運作過程👇
javascript┌───────────────────────────────┐
│ 使用者 (User) │
│ 點擊按鈕 / 輸入查詢文字 │
└──────────────┬────────────────┘
│ 事件觸發 (Event)
▼
┌───────────────────────────────┐
│ JavaScript (AJAX 程式) │
│ fetch('https://mil.../terms') │
│ ↓ │
│ 傳送 HTTP Request 給伺服器 │
└──────────────┬────────────────┘
│
▼
┌───────────────────────────────┐
│ 後端伺服器 (Server) │
│ 接收請求、查資料、回傳 JSON │
│ 例如: {"terms": ["pain", ...]} │
└──────────────┬────────────────┘
│
▼
┌───────────────────────────────┐
│ JavaScript 收到 Response │
│ → await resp.json() 解析資料 │
│ → 更新頁面內容 (DOM 更新) │
│ document.getElementById() │
└──────────────┬────────────────┘
│
▼
┌───────────────────────────────┐
│ 瀏覽器畫面 (Browser View) │
│ 顯示最新結果,不重整整個頁面 │
└───────────────────────────────┘
這兩篇文章是在講:
它說明了:
AJAX 讓你用 JavaScript 在背景送出請求;
不用整頁刷新就能拿資料;
原本是用舊的 XMLHttpRequest;
現在用比較現代的 fetch();
常見用途:聊天室更新、搜尋建議、API 呼叫。
它說:
Axios 是幫助你做 AJAX 的一個函式庫;
它包裝了 fetch(),讓語法更簡潔;
自動轉 JSON、不用自己寫錯誤處理;
能設 header、token、timeout;
適合大型專案。
範例對比:
js// fetch 寫法(原生 AJAX)
fetch('/api/studies')
.then(res => res.json())
.then(data => console.log(data))
.catch(err => console.error(err));
// axios 寫法(第三方套件)
axios.get('/api/studies')
.then(res => console.log(res.data))
.catch(err => console.error(err));
✅ 總結一句話:
AJAX 是「概念」——用 JS 非同步拿資料;
fetch()是「原生工具」;
axios是「更方便的工具包」。
要我幫你畫一個「fetch vs axios 對比」的第二張圖嗎?(用兩邊箭頭比較流程差異、優缺點)
非常好,這兩個詞是理解「AJAX 怎麼讓資料變成畫面」的核心。
我們一步一步來講清楚 👇
await resp.json() 是什麼?這行出現在你的程式裡:
jsconst data = await resp.json();
它的意思是:
「等伺服器回傳的資料(Response)讀取完成後,
把那份資料解析成 JavaScript 可以操作的物件(通常是 JSON 格式)。」
假設伺服器回傳這樣的東西:
json{
"terms": ["moral", "empathy", "justice"]
}
那你執行:
jsconst data = await resp.json();
結果 data 會變成一個 JavaScript 物件:
js{
terms: ["moral", "empathy", "justice"]
}
你就能直接用:
jsconsole.log(data.terms[0]); // moral
await 是「等待」的意思,
因為 resp.json() 需要一點時間(從伺服器讀資料 → 轉成 JS 物件),
如果不用 await,程式會直接跳過它,導致你拿不到資料。
| 代碼 | 意思 |
|---|---|
resp | 從 fetch() 拿回來的「回應物件」(Response object) |
resp.json() | 把回應的 JSON 內容解析成 JS 物件 |
await resp.json() | 等到解析完成再繼續執行下一行 |
「DOM」全名是 Document Object Model,
就是瀏覽器裡代表整個網頁結構的物件系統。
例如你的 HTML:
html<body>
<h1>Hello</h1>
<pre id="out"></pre>
</body>
瀏覽器看到後,會在內部建一棵樹:
php-templateDocument
└── <html>
└── <body>
├── <h1>
└── <pre id="out">
你可以用 JavaScript 操作它:
jsdocument.getElementById('out').textContent = 'Hello world!';
這一行的意思是:
找到 <pre id="out"> 這個元素;
把它裡面的文字改成 "Hello world!"。
結果畫面就會更新。
這整個動作就叫「DOM 更新(DOM manipulation / update)」。
jsconst resp = await fetch("https://mil.psy.ntu.edu.tw:5000/terms");
const data = await resp.json(); // ← 從伺服器拿資料
document.getElementById("out").textContent = JSON.stringify(data, null, 2);
// ↑ 把資料更新到網頁上(DOM 更新)
流程圖是這樣:
javascript伺服器 → JSON 回應 → await resp.json()
↓
data 變成 JS 物件
↓
改變 HTML 元素 → 畫面顯示結果
| 名詞 | 簡單解釋 |
|---|---|
await resp.json() | 等伺服器傳完資料,把 JSON 轉成 JS 物件 |
| DOM 更新 | 用 JS 改 HTML 的內容,讓畫面顯示出新資料 |
要不要我幫你畫一張圖,把「fetch → await resp.json() → DOM 更新 → 使用者看到資料」整個鏈條圖出來?
超好問題 👏
這就進入到你這個作業裡「前端框架」的部分——Bootstrap(或 Tailwind)是用來美化網頁跟排版的,而不是負責資料請求。
讓我幫你拆開來看👇
Bootstrap 是一個 CSS + JS 套件庫,幫你快速建立:
好看的按鈕、表單、表格、排版;
響應式(手機、平板、電腦都適用)網頁;
常用元件(Navbar、Card、Modal、Alert...)。
你不需要自己寫很多 CSS,只要在 HTML 裡套 class 名稱就行。
(它是 “樣式框架”,不是「功能」框架。)
在 HTML <head> 裡引入 CDN(線上版本):
html<head>
<meta charset="UTF-8">
<title>Tren API Demo</title>
<!-- 引入 Bootstrap CSS -->
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
rel="stylesheet"
>
</head>
這一行會讓整個頁面可以使用 Bootstrap 的樣式。
如果你要用它的互動元件(像 dropdown、modal、collapse),
還需要在 <body> 最後引入 JavaScript:
html<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
Bootstrap 的使用方式是用 class 名稱指定樣式。
例如:
| 原本 | 加入 Bootstrap 後 |
|---|---|
<button>Fetch</button> | <button class="btn btn-primary">Fetch</button> |
<div> | <div class="container mt-4"> |
<input> | <input class="form-control" placeholder="Enter term"> |
<pre> | <pre class="bg-light p-3 rounded"> |
這樣按鈕會變藍色、輸入框會有邊框陰影、整體間距會自動變漂亮。
你要讓你的 AJAX 查詢頁面:
有整齊的排版(用 .container、.row、.col);
有漂亮的按鈕(.btn btn-primary、.btn-success);
有輸入框(.form-control);
結果區塊有背景(.bg-light、.rounded、.p-3);
甚至可以加上 Loading Spinner(.spinner-border)。
範例:
html<div class="container mt-5">
<h1 class="mb-4 text-center">Tren API Frontend</h1>
<div class="mb-3">
<button id="btnAll" class="btn btn-primary">Get All Terms</button>
</div>
<div class="mb-3">
<input id="termInput" class="form-control" placeholder="Enter a term">
<button id="btnTerm" class="btn btn-success mt-2">Get Related Terms</button>
</div>
<div class="mb-3">
<input id="queryInput" class="form-control" placeholder="Enter a query">
<button id="btnStudy" class="btn btn-info mt-2">Search Studies</button>
</div>
<pre id="out" class="bg-light p-3 rounded border">Waiting...</pre>
</div>
整體結構不變,只是加了一堆 Bootstrap class,外觀會漂亮許多。
| 元件 | 作用 | 放在哪 |
|---|---|---|
| Bootstrap CSS CDN | 套用樣式 | <head> 裡 <link> |
| Bootstrap JS CDN | 啟用互動元件(可選) | <body> 結尾 <script> |
| class 屬性 | 實際應用樣式 | 在 HTML tag 上,例如 class="btn btn-primary" |
簡單講:
Bootstrap 不會讓你的 AJAX 跑得更好,
但它會讓你的頁面看起來像「一個真的網站」。
要不要我幫你把你現在那份有註解的 index.html 加上 Bootstrap,
讓它變成又漂亮又能查 /terms、/terms/<t1>、/query/<q>/studies 的完整版?
太好了~我幫你用 Tailwind 做了一個「一頁式、三個端點齊備」的範例頁面,直接放到你的 repo 當 index.html 就能跑(用 Tailwind Play CDN,不用任何安裝或建置)。
我已經把完整檔案放到右邊的 Canvas:「Tren API Frontend (Tailwind)」。你可以直接複製整份內容覆蓋你的 index.html。
簡短說明怎麼用:
上方有三個操作:
Get all terms → 叫 /terms
Related terms → 輸入一個 term 叫 /terms/<t1>
Search studies → 輸入關鍵字叫 /query/<q>/studies
下方「Result」區有 Pretty / Raw JSON 兩種檢視。
內建 loading spinner、HTTP 狀態列、錯誤訊息(含 CORS 提示)。
全部樣式用 Tailwind,無需安裝,GitHub Pages 直接可用。
接下來建議:
把 Canvas 的內容複製到你的 index.html。
push 到 repo 的 main。
GitHub → Settings → Pages:Branch: main、Folder: /(root)。
打開你的 Pages 網址測試三個按鈕。
要不要我幫你把頁面加上「結果表格版(把 studies 陣列渲染成表格)」或是加一個簡單的 Navbar?
不一定是「有錯誤」,要看是哪裡的「訊息」你在講喔 😄
讓我幫你釐清幾種狀況:
那代表在你的 fetch() 呼叫裡的 try–catch 抓到了例外
啊~這個畫面不是「程式錯誤」,是 VS Code 的 Git 標記 🟠。
你看到的紅橘色文字(index.html)+右邊那個 「2 M」 的意思是:
| 標記 | 意思 |
|---|---|
| M | Modified → 這個檔案有改動但還沒 git add 或 commit。 |
| 數字 2 | 代表這個檔案有 2 行被改動。 |
| 紅橘色檔名 | 表示目前這個檔案的修改還沒被 Git 暫存。 |
它只是提醒你「這個檔案跟 Git 儲存庫裡版本不同步」,不影響 HTML 或 AJAX 是否能跑。
如果你想把改動存進 Git:
bashgit add index.html
git commit -m "update index.html"
git push
推上去後,VS Code 的檔案名會變回白色/灰色,表示版本一致。
簡單說:
紅橘色代表「你有改過這個檔案,還沒 commit」,
不是語法錯誤,也不會影響你網站能不能跑。
完全正常 👍
這段訊息其實是 Git 在提醒你「你改了東西,但還沒告訴 Git 要記錄下來」。
我們把它逐行拆開講一下:
rustChanges not staged for commit:
→ 你有改動,但還沒被「stage」(暫存)起來。
pgsql(use "git add <file>..." to update what will be committed)
→ Git 告訴你:「如果你想把這些改動放進下一次 commit,就用 git add」。
pgsql(use "git restore <file>..." to discard changes in working directory)
→ 如果你想「放棄這次修改、還原成之前版本」,就用 git restore。
makefilemodified: index.html
→ 表示你改過的檔案是 index.html。
pgsqlno changes added to commit (use "git add" and/or "git commit -a")
→ 目前沒有東西被 staged,Git 不會 commit。
在終端機輸入這兩行:
bashgit add index.html
git commit -m "update index.html with Tailwind frontend"
然後如果你要推上 GitHub:
bashgit push
輸入:
bashgit restore index.html
它會把檔案還原到你上次 commit 的版本。
| 你的狀況 | 要下的指令 |
|---|---|
| 想保存修改並提交 | git add index.html → git commit -m "..." → git push |
| 想丟掉修改回到原本版本 | git restore index.html |
你想讓我幫你看一下整個 repo 的 git 狀態(例如還有哪些檔案改過),然後告訴你最安全的下一步指令嗎?
懂了—你要的是多頁式前端:首頁顯示連線資訊與端點清單,每個端點各有自己的頁面與 AJAX 呈現,而不是全擠在一頁。下面給你一個可直接部署在 GitHub Pages 的最小可行版本(Tailwind、純前端、無需安裝)。
bash/ (repo root) ├─ index.html ← 首頁:顯示 Server 與 Endpoints、範例連結 ├─ terms.html ← /terms:列出所有 terms ├─ term.html ← /terms/<term>:查某 term 的相關 terms └─ studies.html ← /query/<q_string>/studies:查研究
顯示「連接上 Tren’s backend」與 Server URL
列出三個 API endpoints 與說明
放到各子頁的連結與官方範例 URL
html<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Tren API Frontend · Home</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-slate-50 text-slate-800">
<header class="bg-white border-b">
<div class="max-w-4xl mx-auto px-4 py-4">
<h1 class="text-2xl font-semibold">Tren API Frontend</h1>
</div>
</header>
<main class="max-w-4xl mx-auto px-4 py-6 space-y-6">
<section class="rounded-xl border bg-white p-5">
<h2 class="text-lg font-semibold mb-2">連接上 Tren’s backend</h2>
<p class="text-sm">Server:
<a class="text-blue-600 underline" href="https://mil.psy.ntu.edu.tw:5000" target="_blank">
https://mil.psy.ntu.edu.tw:5000
</a>
</p>
</section>
<section class="rounded-xl border bg-white p-5 space-y-4">
<h2 class="text-lg font-semibold">API endpoints</h2>
<ul class="list-disc pl-5 space-y-1 text-sm">
<li><code>/terms</code> :look up all available terms – <a class="text-blue-600 underline" href="./terms.html">前往頁面</a></li>
<li><code>/terms/<term></code> :look up terms associated with <code>term</code> – <a class="text-blue-600 underline" href="./term.html">前往頁面</a></li>
<li><code>/query/<query_string>/studies</code> :logical search – <a class="text-blue-600 underline" href="./studies.html">前往頁面</a></li>
</ul>
</section>
<section class="rounded-xl border bg-white p-5 space-y-2">
<h3 class="font-semibold">Search examples</h3>
<ul class="list-disc pl-5 text-sm space-y-1">
<li><a class="text-blue-600 underline" target="_blank" href="https://mil.psy.ntu.edu.tw:5000/terms">/terms</a></li>
<li><a class="text-blue-600 underline" target="_blank" href="https://mil.psy.ntu.edu.tw:5000/terms/amygdala">/terms/amygdala</a></li>
<li><a class="text-blue-600 underline" target="_blank" href="https://mil.psy.ntu.edu.tw:5000/query/amygdala%20not%20emotion/studies">/query/amygdala%20not%20emotion/studies</a></li>
</ul>
</section>
</main>
</body>
</html>
載入即用 AJAX 取回所有 terms,渲染為 tag 清單
有重新整理按鈕
html<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>/terms · All Terms</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-slate-50 text-slate-800">
<nav class="bg-white border-b">
<div class="max-w-4xl mx-auto px-4 py-3 flex items-center gap-4">
<a class="text-slate-700 hover:underline" href="./index.html">Home</a>
<span class="text-slate-400">/</span>
<span class="font-medium">/terms</span>
</div>
</nav>
<main class="max-w-4xl mx-auto px-4 py-6 space-y-4">
<div class="flex items-center justify-between">
<h1 class="text-xl font-semibold">All available terms</h1>
<button id="refresh" class="rounded-lg bg-blue-600 text-white px-3 py-1.5">Refresh</button>
</div>
<div id="status" class="text-sm text-slate-500">Ready.</div>
<div id="out" class="rounded-xl border bg-white p-4 min-h-40"></div>
</main>
<script>
const API = 'https://mil.psy.ntu.edu.tw:5000';
const out = document.getElementById('out');
const statusEl = document.getElementById('status');
function tagList(arr){
return `<div class="flex flex-wrap gap-2">
${arr.map(t=>`<span class="inline-flex items-center rounded-full bg-slate-100 px-3 py-1 text-xs">${escapeHTML(t)}</span>`).join('')}
</div>`;
}
const escapeHTML = s => String(s).replaceAll('&','&').replaceAll('<','<').replaceAll('>','>').replaceAll('"','"').replaceAll("'","'");
async function load(){
statusEl.textContent = 'GET /terms';
out.innerHTML = '<div class="text-slate-600 text-sm">Loading…</div>';
const resp = await fetch(`${API}/terms`);
const data = await resp.json();
statusEl.textContent = `HTTP ${resp.status} ${resp.statusText}`;
out.innerHTML = Array.isArray(data) ? tagList(data) : `<pre class="bg-slate-50 border rounded p-3 overflow-auto">${escapeHTML(JSON.stringify(data,null,2))}</pre>`;
}
document.getElementById('refresh').addEventListener('click', load);
load();
</script>
</body>
</html>
有輸入框與「Search」按鈕
呼叫 /terms/<term>,把回傳(通常是 {term: [...]})渲染成 key + tag 清單
html<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>/terms/<term> · Related Terms</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-slate-50 text-slate-800">
<nav class="bg-white border-b">
<div class="max-w-4xl mx-auto px-4 py-3 flex items-center gap-4">
<a class="text-slate-700 hover:underline" href="./index.html">Home</a>
<span class="text-slate-400">/</span>
<span class="font-medium">/terms/<term></span>
</div>
</nav>
<main class="max-w-4xl mx-auto px-4 py-6 space-y-4">
<h1 class="text-xl font-semibold">Related terms</h1>
<div class="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
<input id="term" class="flex-1 rounded-lg border px-3 py-2" placeholder="e.g. amygdala" />
<button id="go" class="rounded-lg bg-blue-600 text-white px-3 py-2">Search</button>
</div>
<div id="status" class="text-sm text-slate-500">Ready.</div>
<div id="out" class="rounded-xl border bg-white p-4 min-h-40"></div>
</main>
<script>
const API = 'https://mil.psy.ntu.edu.tw:5000';
const out = document.getElementById('out');
const statusEl = document.getElementById('status');
const escapeHTML = s => String(s).replaceAll('&','&').replaceAll('<','<').replaceAll('>','>').replaceAll('"','"').replaceAll("'","'");
function renderObject(obj){
return Object.entries(obj).map(([k, v])=>{
if (Array.isArray(v)){
return `<div class="mb-4">
<h3 class="text-sm font-semibold mb-2">${escapeHTML(k)}</h3>
<div class="flex flex-wrap gap-2">
${v.map(x=>`<span class="inline-flex items-center rounded-full bg-slate-100 px-3 py-1 text-xs">${escapeHTML(String(x))}</span>`).join('')}
</div>
</div>`;
}
return `<div class="mb-4">
<h3 class="text-sm font-semibold mb-2">${escapeHTML(k)}</h3>
<pre class="bg-slate-50 border rounded p-3 overflow-auto">${escapeHTML(JSON.stringify(v,null,2))}</pre>
</div>`;
}).join('');
}
async function load(term){
if (!term){ statusEl.textContent = '請輸入 term'; return; }
const url = `${API}/terms/${encodeURIComponent(term)}`;
statusEl.textContent = `GET ${url}`;
out.innerHTML = '<div class="text-slate-600 text-sm">Loading…</div>';
const resp = await fetch(url);
const data = await resp.json();
statusEl.textContent = `HTTP ${resp.status} ${resp.statusText}`;
out.innerHTML = (data && typeof data === 'object') ? renderObject(data)
: `<pre class="bg-slate-50 border rounded p-3 overflow-auto">${escapeHTML(JSON.stringify(data,null,2))}</pre>`;
}
document.getElementById('go').addEventListener('click', ()=> load(document.getElementById('term').value.trim()));
</script>
</body>
</html>
輸入查詢字串(自動 URL encode),呼叫 /query/<q>/studies
若回傳陣列物件,渲染為表格;否則顯示原始 JSON
html<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>/query/<q>/studies · Search</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-slate-50 text-slate-800">
<nav class="bg-white border-b">
<div class="max-w-4xl mx-auto px-4 py-3 flex items-center gap-4">
<a class="text-slate-700 hover:underline" href="./index.html">Home</a>
<span class="text-slate-400">/</span>
<span class="font-medium">/query/<q>/studies</span>
</div>
</nav>
<main class="max-w-4xl mx-auto px-4 py-6 space-y-4">
<h1 class="text-xl font-semibold">Search studies</h1>
<div class="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
<input id="q" class="flex-1 rounded-lg border px-3 py-2" placeholder='e.g. amygdala not emotion' />
<button id="go" class="rounded-lg bg-blue-600 text-white px-3 py-2">Search</button>
</div>
<p class="text-xs text-slate-500">範例:<code>amygdala not emotion</code> → 會請求
<code>/query/amygdala%20not%20emotion/studies</code></p>
<div id="status" class="text-sm text-slate-500">Ready.</div>
<div id="out" class="rounded-xl border bg-white p-4 min-h-40 overflow-auto"></div>
</main>
<script>
const API = 'https://mil.psy.ntu.edu.tw:5000';
const out = document.getElementById('out');
const statusEl = document.getElementById('status');
const escapeHTML = s => String(s).replaceAll('&','&').replaceAll('<','<').replaceAll('>','>').replaceAll('"','"').replaceAll("'","'');
function renderTable(rows){
if (!Array.isArray(rows) || rows.length === 0){
return '<div class="text-slate-600 text-sm">No results.</div>';
}
const cols = Array.from(new Set(rows.flatMap(obj => Object.keys(obj))));
const head = cols.map(c=>`<th class="px-3 py-2 text-left text-xs font-semibold bg-slate-50">${escapeHTML(c)}</th>`).join('');
const body = rows.map(r=>`<tr>${cols.map(c=>`<td class="px-3 py-2 border-t align-top text-sm">${escapeHTML(String(r[c] ?? ''))}</td>`).join('')}</tr>`).join('');
return `<div class="overflow-x-auto"><table class="min-w-full border rounded">${head ? `<thead><tr>${head}</tr></thead>`:''}<tbody>${body}</tbody></table></div>`;
}
async function load(q){
if (!q){ statusEl.textContent = '請輸入查詢字串'; return; }
const url = `${API}/query/${encodeURIComponent(q)}/studies`;
statusEl.textContent = `GET ${url}`;
out.innerHTML = '<div class="text-slate-600 text-sm">Loading…</div>';
const resp = await fetch(url);
const data = await resp.json();
statusEl.textContent = `HTTP ${resp.status} ${resp.statusText}`;
if (Array.isArray(data) && data.every(x => x && typeof x === 'object')){
out.innerHTML = renderTable(data);
} else {
out.innerHTML = `<pre class="bg-slate-50 border rounded p-3">${escapeHTML(JSON.stringify(data,null,2))}</pre>`;
}
}
document.getElementById('go').addEventListener('click', ()=> load(document.getElementById('q').value.trim()));
</script>
</body>
</html>
把四個檔案加到 repo root。
git add . && git commit -m "multi-page Tailwind AJAX frontend" && git push
GitHub → Settings → Pages:Source: Deploy from a branch、Branch: main、Folder: /(root)
你的 Pages 首頁會是 index.html;其餘頁面用相對連結開啟。
小提醒:查詢字串/term 都已做
encodeURIComponent,可安心輸入有空白的內容(如amygdala not emotion)。
如果你要,我也可以把這四個檔案直接放到右側 Canvas(或幫你把你現有的 repo 內容改寫成這個多頁式結構)。
可以!VS Code 內建就能「預覽 HTML 頁面長相」,有幾種方式,下面是最簡單的方法 👇
在 VS Code 左側的檔案總管中,右鍵你的 index.html → 選擇 “Reveal in File Explorer”。
在檔案總管裡,雙擊 index.html(或右鍵 → 用 Chrome / Edge 開啟)。
👉 這樣就能直接在瀏覽器看到頁面效果(Tailwind CDN 會自動生效)。
打開 Extensions(左邊積木圖示),搜尋 Live Server,點「Install」。
安裝好後,右下角會出現「Go Live」按鈕。
點「Go Live」 → 瀏覽器會自動打開一個網址類似:
arduinohttp://127.0.0.1:5500/index.html
每次你修改 HTML 儲存後,瀏覽器會自動重新載入預覽結果。
💡這是最常見的前端開發方式:即時預覽+Tailwind CDN 都會正常載入。
如果你用的是新版 VS Code(2023 之後版本):
打開 index.html
右上角(檔名旁邊)會看到一個小的「地球 🌐」圖示
→ 點一下就能在 VS Code 裡直接開 preview 視窗。
不要直接用 file:// 開啟 再去打 AJAX(fetch),有時會被瀏覽器擋下來(CORS / local file security)。
如果你只是看排版沒差,但如果要測試 fetch(),請用「Live Server」這類本地伺服器開。
GitHub Pages 部署後會是 HTTPS 網址,也能直接測 fetch。
要不要我幫你設定一下 Live Server 的使用步驟(包含自動重新整理和多頁預覽)?
對,你的畫面一直顯示 Loading… 就代表 AJAX(fetch)發出的請求沒有成功拿到資料,而不是語法錯。
這通常是下面幾種情況之一👇
因為你現在是從 本機端(127.0.0.1:5500) 用 AJAX 去抓
https://mil.psy.ntu.edu.tw:5000/terms/fear。
瀏覽器會擋下這種「跨來源請求」(Cross-Origin Request)。
你的前端網址 (localhost) 跟後端 (mil.psy.ntu.edu.tw) 不同網域,所以被擋了。
打開 Chrome → 檢查 (F12) → Console 或 Network 頁籤
如果看到:
pgsqlAccess to fetch at 'https://mil.psy.ntu.edu.tw:5000/terms/fear'
from origin 'http://127.0.0.1:5500' has been blocked by CORS policy
那就確定是這個問題。
這是課堂作業常見情況,老師的伺服器通常 不允許本機直接連。
要測試的話:
把網頁部署到 GitHub Pages(因為那是 HTTPS domain),
→ 再從你的 GitHub Pages 網址打開,就能連成功。
或者用瀏覽器外掛(像 CORS Unblock)暫時解除(開發測試可用)。
如果伺服器真的沒回資料(但沒有 CORS 錯誤),
在 Console 會看到:
vbnetTypeError: Failed to fetch
這時可能:
老師的伺服器暫時沒開;
或防火牆擋住你的請求。
可以直接在瀏覽器打開:
👉 https://mil.psy.ntu.edu.tw:5000/terms/fear
看能不能顯示 JSON。
如果那邊能開,就代表 server 正常,只是瀏覽器不讓你 AJAX 連。
若你是用 VS Code 直接開 file(網址長這樣):
perlfile:///C:/Users/.../term.html
那就一定會被 CORS 擋。
用「Live Server」外掛開會變成:
arduinohttp://127.0.0.1:5500/term.html
這樣才正常。
| 情況 | 現象 | 解法 |
|---|---|---|
| CORS 被擋 | Console 顯示「blocked by CORS policy」 | 用 GitHub Pages(https)開、或暫時裝 CORS 外掛 |
| Server 沒開 | Console 顯示「Failed to fetch」 | 直接測試網址能不能開 |
| 本地 file:// | fetch 永遠卡在 Loading | 用 Live Server 啟動 |
如果你打開瀏覽器的 開發者工具 (F12 → Console),
貼給我上面錯誤訊息的一段(那行紅字),
我可以告訴你是哪一種問題、該用哪個方式解。
要我幫你看嗎?
非常好 👍
這個步驟是前端開發最重要的技能之一。下面一步一步教你在瀏覽器裡看 Console(主控台):
1️⃣ 打開你的頁面(例如 http://127.0.0.1:5500/term.html)
2️⃣ 在網頁上按下:
Windows → Ctrl + Shift + I
Mac → Command + Option + I
3️⃣ 瀏覽器右邊會出現開發者工具。
你會看到上方幾個分頁:
arduinoElements | Console | Sources | Network | ...
4️⃣ 點選 「Console」 分頁。
這裡會顯示所有:
程式錯誤(紅字)
警告(黃色)
你 console.log() 的輸出
AJAX 錯誤(像是 CORS, Failed to fetch)
如果你現在執行 /terms/fear 一直 Loading,
可能會在 Console 看到像這樣的紅字:
csharpAccess to fetch at 'https://mil.psy.ntu.edu.tw:5000/terms/fear'
from origin 'http://127.0.0.1:5500'
has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.
或是:
pgsqlUncaught (in promise) TypeError: Failed to fetch
1️⃣ 開啟開發者工具 → 點「Network」分頁
2️⃣ 按 F5 重新整理頁面
3️⃣ 你會看到所有請求列表
4️⃣ 找到那個 URL(例如 /terms/fear)
→ 點一下可以看 Response / Headers / Status code(200、404、CORS 等)
| 要看什麼 | 分頁 | 你會看到什麼 |
|---|---|---|
| 程式錯誤、CORS 錯誤 | Console | 紅字訊息 |
| AJAX 是否送出成功 | Network | 請求清單與狀態碼 |
| HTML 排版結構 | Elements | 頁面 DOM 樹 |
👉 你可以現在打開 Console,看紅字錯誤貼給我(只要貼一兩行訊息),
我就可以馬上判斷是不是 CORS 問題、還是 fetch 錯誤。
太好了,你截的 Console 錯誤非常清楚,我幫你分析 👇
pgsqlFailed to load resource: net::ERR_CONNECTION_TIMED_OUT
mil.psy.ntu.edu.tw:5000/terms/fear
→ 代表 瀏覽器連不上老師的伺服器。
不是語法錯,也不是 CORS,是 連線逾時(server 沒回應)。
下面這行只是接著顯示因為沒拿到資料:
pgsqlUncaught (in promise) TypeError: Failed to fetch
試著直接在瀏覽器網址列輸入:
bashhttps://mil.psy.ntu.edu.tw:5000/terms/fear
如果畫面出現:
❌ 「無法連線」或轉圈圈很久 → 伺服器現在真的沒開(timeout)
✅ 顯示 JSON → 就是瀏覽器的 CORS 問題(但目前看起來不是)
| 原因 | 說明 | 解法 |
|---|---|---|
| 💤 老師伺服器暫時關掉 | 課程的 backend 常只在上課時間開 | 等他重新開、或寄信詢問 |
| 🔒 學校防火牆或 VPN | NTU 校園網內部主機外部無法連 | 改用校內 VPN、或請老師開外部 access |
| 🌐 Port 5000 被擋 | 有些網路環境不允許連 5000 port | 嘗試不同網路(家裡 Wi-Fi / 手機熱點) |
開 Chrome → 輸入 https://mil.psy.ntu.edu.tw:5000 看能否開啟首頁。
如果開不起來,就不是你的程式問題,是伺服器沒開。
如果能開但 AJAX 不行,我再教你加 proxy / 用 Pages 測試。
cdn.tailwindcss.com should not be used in production...
不用理它,Tailwind 官方只是提醒「這個 CDN 是開發用的」,
作業、GitHub Pages 用這個完全沒問題。
👉 你現在可以幫我試試直接在瀏覽器開
https://mil.psy.ntu.edu.tw:5000/terms
看看有沒有反應?
如果整個 Timeout 或顯示「無法連線」,就確定是 server 沒開。
看起來是伺服器連不上(逾時),不是你前端的問題。你之前連得到、現在連不到,最常見是:後端暫時沒開 / 校外網路被擋 / 連線環境(VPN、防火牆、ISP)變動。
下面給你一套「確認→排除→暫時替代」的快速流程。
在瀏覽器打:
bashhttps://mil.psy.ntu.edu.tw:5000 https://mil.psy.ntu.edu.tw:5000/terms
如果兩個都 Timeout,就很可能是伺服器沒開或 5000 port 被擋。
打開 PowerShell:
powershell# 1) DNS 解析 nslookup mil.psy.ntu.edu.tw # 2) 試連 TCP 5000 Test-NetConnection mil.psy.ntu.edu.tw -Port 5000 # 3) 快速發個 HEAD 請求(有回應就會顯示狀態碼) curl -I https://mil.psy.ntu.edu.tw:5000/terms
TcpTestSucceeded : True → 網路通;False → 網路/伺服器/防火牆擋住。
curl -I 有狀態碼(200/4xx/5xx)就代表打得到。
換家裡/宿舍 Wi-Fi ↔ 手機熱點
關掉 VPN / 公司校園代理 / 防毒網路盾
問同學是否能連:如果大家都不行=後端沒開;只有你不行=你這邊的網路問題
在後端沒開之前,先用 本地 mock 資料 讓頁面能跑起來、UI 做完:
在 repo 新增資料夾 data/,放三個檔案:
data/terms.json:例如
json["amygdala","fear","emotion","moral"]
data/terms_fear.json:
json{"fear":["anxiety","threat","amygdala"]}
data/studies_amygdala_not_emotion.json:
json[
{"id":1,"title":"Amygdala study A","year":2021},
{"id":2,"title":"Amygdala study B","year":2019}
]
在你的 fetchJSON(path) 加入逾時 + 本地 fallback:
jsasync function fetchJSON(path) {
const API = 'https://mil.psy.ntu.edu.tw:5000';
const url = `${API}${path}`;
// 10 秒逾時避免卡在 Loading
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 10000);
try {
const resp = await fetch(url, { signal: controller.signal });
clearTimeout(timer);
if (!resp.ok) throw new Error(`HTTP ${resp.status} ${resp.statusText}`);
const ct = resp.headers.get('content-type') || '';
return ct.includes('application/json') ? resp.json() : resp.text();
} catch (err) {
// === 本地 mock ===
console.warn('Backend unreachable, using local mock:', err.message);
const mockMap = {
'/terms': 'data/terms.json',
'/terms/fear': 'data/terms_fear.json',
'/query/amygdala%20not%20emotion/studies':
'data/studies_amygdala_not_emotion.json',
};
const mock = mockMap[path];
if (!mock) throw err; // 沒對應 mock 就把錯丟出去
const r = await fetch(mock); // 讀本地靜態 JSON
return r.json();
}
}
這樣你在本機或 GitHub Pages 都能看到 UI 正常運作;等老師後端恢復,把 mock 移除或保留當備援即可。
把之前的 Loading 改成有逾時與錯誤訊息(搭配上面 AbortController):
jsspinner('Loading…');
fetchJSON('/terms/fear')
.then(data => renderPretty(data))
.catch(err => {
outEl.innerHTML =
`<div class="text-red-600 text-sm">連線失敗:${escapeHTML(err.message)}</div>
<div class="text-slate-600 text-xs mt-2">可能原因:伺服器未啟動、Port 5000 被防火牆阻擋、或網路不穩。已建議暫用本地 mock。</div>`;
});
Test-NetConnection 顯示 TcpTestSucceeded : False;
多個同學也連不到;
或你開學校 VPN 也一樣;
→ 幾乎可以確定是後端沒開或對外封鎖,直接把你的測試結果截圖給老師就好。
需要的話,我可以把你現在的四個頁面都加上 逾時 + fallback mock 的版本,這樣你今天就能把作業前端做完並部署到 GitHub Pages。
好了!我把你手繪的版面做成一個 單頁、Tailwind + AJAX 的實作,包含:
左側側欄:Show all keywords(/terms)+ History(localStorage 記錄你的查詢)
右側主區:
Search for associated terms(/terms/<term>)→ 顯示「co-occur rate & word」風格的卡片/籤籤、可捲動
Search for studies(/query/<q>/studies)→ 結果以 表格 呈現、可橫向捲動
每個查詢上方會顯示實際 GET URL(藍色連結,方便你點出去驗證)
內建 Loading spinner、逾時/錯誤提示
Mock fallback:若老師後端暫時連不到,會讀 data/*.json 的本地假資料,UI 不會卡住(你可自行放入對應檔案)
我已把完整的 HTML 放到右邊 Canvas:
「NeuroSearch – Single-Page (Tailwind + AJAX)」
直接複製成 index.html 就能用(Tailwind CDN,無需安裝)。
pgsql/ (repo root)
└─ index.html ← 我幫你做的這一頁
└─ data/ ← (可選) mock JSON,後端沒開也能預覽
├─ terms.json
├─ terms_fear.json
└─ studies_amygdala_not_emotion.json
data/terms.json
json["amygdala","fear","emotion","moral"]
data/terms_fear.json
json{"fear":{"anxious":20,"threat":12,"panic":7,"amygdala":25}}
data/studies_amygdala_not_emotion.json
json[
{"id":1,"title":"Amygdala Study A","authors":"Smith et al.","year":2021},
{"id":2,"title":"Amygdala Study B","authors":"Chen","year":2019}
]
當你按下「Search」時,如果打不到後端(timeout 或關站),頁面會自動讀這些 mock 檔,讓你可以先完成前端與互動流程;等後端恢復,直接刪或保留 mock 都行。
你的紅字需求(顯示建議關鍵字、co-occur rate、表格等)都已對應到介面文案與渲染邏輯。
History 會記錄三種操作(terms/related/studies),保留最近 50 筆,支援清空。
所有輸入都已 encodeURIComponent,支援 amygdala not emotion 這種有空白的邏輯查詢。
如果你想把這一頁拆成多頁(像你之前的 terms.html / term.html / studies.html),我也可以幫你把這個設計「切版成多頁但沿用同一套樣式與函式」。另外也可改成 深色主題 或加上 Navbar/Logo。
我已經把所有按鈕的主色系改成了 #76AEA2(深色 hover 版為 #5E8C84),整體 Tailwind 配色保持一致。現在所有 Search、Show all keywords 按鈕都會以這個綠色呈現。是否也想要我同步讓左側 sidebar 的綠色區塊(如 History 標題或邊框)一起使用這個色號?
✅ 沒錯,完全正確。
要讓這份 index.html 能在老師伺服器關掉時仍可正常運作(AJAX 不報錯),你只要照下面方式做:
在你的專案根目錄(跟 index.html 同層)建立一個資料夾:
pgsql/ (你的repo)
├── index.html
└── data/
├── terms.json
├── terms_fear.json
└── studies_amygdala_not_emotion.json
data/terms.json對應 /terms 端點(顯示所有關鍵字)
json["amygdala", "fear", "emotion", "moral", "trust"]
data/terms_fear.json對應 /terms/fear 端點(顯示 fear 的相關詞)
json{
"fear": {
"anxious": 20,
"threat": 12,
"amygdala": 25,
"panic": 8
}
}
data/studies_amygdala_not_emotion.json對應 /query/amygdala%20not%20emotion/studies 端點
json[
{
"id": 1,
"title": "Amygdala Activity in Emotional Regulation",
"authors": "Smith et al.",
"year": 2020,
"journal": "NeuroImage"
},
{
"id": 2,
"title": "Amygdala Response to Threat without Emotion",
"authors": "Chen & Lee",
"year": 2022,
"journal": "Frontiers in Neuroscience"
}
]
data/ 資料夾在 index.html 的 fetchJSON() 裡有這段程式碼:
jsconst mockMap = {
'/terms': 'data/terms.json',
'/terms/fear': 'data/terms_fear.json',
'/query/amygdala%20not%20emotion/studies': 'data/studies_amygdala_not_emotion.json'
};
這代表:
當 AJAX 打不開老師的伺服器時,
程式會自動轉去抓 data/ 資料夾裡的對應檔案。
檔案名稱與路徑要一模一樣。
不需要開伺服器就能用,但如果要測 AJAX,建議用 Live Server(localhost:5500)開,不要直接 file://。
之後老師的後端恢復後,這些 mock 會自動被忽略(程式會優先連線成功的伺服器)。
要不要我幫你加上另外幾個 mock,例如 /terms/amygdala 和 /query/fear/studies,讓你測三種情境?
好了!我幫你做了三個更新:
側欄卡片背景
allTerms、history 兩個盒子改成 bg-white/40 + backdrop-blur + border,在深色底上更清楚。
盒子內文改成深灰字,對比更好。
terms 標籤樣式
標籤(chips)改為 白底 + 灰字(bg-white text-slate-600),在深色側欄不會再整塊變白看不到字。
History「點擊重播」功能
每筆歷史記錄變成可點擊的 .hist-item。
點下去會自動重跑該操作:
terms → 重新執行「Show all keywords」
related → 把 term 填回輸入框並送出
studies → 把 query 填回輸入框並送出
如果還想把左側「Show all keywords」按鈕也換成白字深灰邊框(或改尺寸、圓角大小),跟我說要的感覺,我直接再調。
我看到了問題點:badge() 這個函式在你那份檔案裡多了一個多餘的 </span>,導致字串模板沒正確收斂,後面整段 JS 都被 VS Code 當成錯誤。這會連帶讓 spinner() 以下全部被標紅。
我已經幫你把 Canvas 裡的程式修正為下面這個版本(請在你的檔案中用它覆蓋 badge()):
jsfunction badge(txt){
return `<span class="inline-flex items-center rounded-full bg-white text-slate-600 px-3 py-1 text-xs mr-2 mb-2">${escapeHTML(txt)}</span>`;
}
快速檢查清單:
badge() 的 return 只有一個 <span>...</span>,尾端不要再多一個 </span>.
背後的反引號(`)要配對完整。
存檔後可在 VS Code 右上角「Problems」面板確認是否清空;或在瀏覽器 Console 檢查是否沒有 Uncaught SyntaxError。
如果還有紅線,貼出「第一個」紅線對應的錯誤訊息給我(通常是最上方那個),我再幫你逐行排。
可能是下面其中一個原因(照順序檢一下,通常第 1~2 點就能找出問題):
確認有 data/terms.json,而且是有效 JSON
放在與 index.html 同層的 data/ 資料夾。
內容例子(一定要是陣列或物件):
json["amygdala","fear","emotion","moral"]
直接打開測試
在瀏覽器網址列開:http://127.0.0.1:5500/data/terms.json
如果 404 或打不開,代表路徑或 Live Server 目錄不對。
看 Console(F12 → Console)
如果看到 Failed to fetch 或 404,就表示 mock 檔案找不到。
如果沒有任何錯誤,但畫面空白,多半是回來的是「空陣列」。
伺服器還是關的 → 會走 mock fallback,但前提是 data/terms.json 存在。
data/terms.json 不在正確位置 / 名稱不同 → 會在 Console 出現 404。
JSON 不是合法格式 → Console 出現 Unexpected token ... in JSON。
把 btnAll 的事件和 renderAllTerms() 換成下面這個版本(只要換這兩段即可):
js// 1) 更強的顯示:空資料 / 錯誤都會顯示在盒子裡
function renderAllTerms(list){
const box = document.getElementById('allTerms');
try {
if (!list) {
box.innerHTML = '<div class="text-sm text-red-600">No data returned.</div>';
return;
}
if (Array.isArray(list)) {
if (list.length === 0) {
box.innerHTML = '<div class="text-sm text-slate-600">(no terms)</div>';
return;
}
box.innerHTML = `<div class="flex flex-wrap">${list.map(t =>
`<span class="inline-flex items-center rounded-full bg-white text-slate-600 px-3 py-1 text-xs mr-2 mb-2">${escapeHTML(t)}</span>`
).join('')}</div>`;
return;
}
// 若是物件就印 JSON
box.innerHTML = `<pre class="text-xs">${escapeHTML(JSON.stringify(list,null,2))}</pre>`;
} catch (e) {
box.innerHTML = `<div class="text-sm text-red-600">Render error: ${escapeHTML(e.message)}</div>`;
}
}
// 2) 點擊 Show all keywords 的處理:把錯誤訊息也丟到畫面
document.getElementById('btnAll').addEventListener('click', async () => {
const box = document.getElementById('allTerms');
box.innerHTML = `<div class="text-slate-600 text-sm">Loading all terms…</div>`;
try {
console.log('GET /terms (backend or mock)');
const data = await fetchJSON('/terms');
console.log('DATA /terms =', data);
renderAllTerms(data);
pushHistory('terms', '(all)');
} catch (e) {
console.error('Failed /terms:', e);
box.innerHTML = `<div class="text-sm text-red-600">Load failed: ${escapeHTML(e.message)}</div>
<div class="text-xs text-slate-600 mt-1">請確認 data/terms.json 是否存在且能由瀏覽器開啟。</div>`;
}
});
你是用 Live Server(http://127.0.0.1:5500)開嗎?用 file:// 會讓 fetch('data/...') 失敗。
data/terms.json 檔名或位置錯了。→ 直接打開 http://127.0.0.1:5500/data/terms.json 測一下最準。
如果你把 Console 的紅字貼一行給我(或告訴我 http://127.0.0.1:5500/data/terms.json 的結果),我就能精準指出是哪一步沒對上。
OK,先別急~看起來是「按鈕事件沒有被綁上」或「程式根本沒跑到事件那段」。給你一組最小偵錯補丁,貼到你檔案底部 <script> 的「事件綁定那一區」即可,會:
確保在 DOM Ready 後才綁事件
每個按鈕被點時 Console 會出現 log
任何例外都會在 Console 與畫面上顯示
也順便檢查 data/terms.json 是否能被讀到
把你原本的三個 addEventListener 全部換成下面這段:
html<script>
// ---- 這三行放在檔案頂部也可以,方便你看有沒有成功載入 ----
console.log('[NeuroSearch] script loaded at', new Date().toISOString());
window.addEventListener('error', (e) => console.error('[window.onerror]', e.message, e));
window.addEventListener('unhandledrejection', (e) => console.error('[unhandledrejection]', e.reason));
document.addEventListener('DOMContentLoaded', () => {
console.log('[NeuroSearch] DOM ready');
const btnAll = document.getElementById('btnAll');
const btnRelated = document.getElementById('btnRelated');
const btnStudies = document.getElementById('btnStudies');
// 檢查元素是否存在
[['btnAll', btnAll], ['btnRelated', btnRelated], ['btnStudies', btnStudies]].forEach(([name, el]) => {
if (!el) console.warn(`[WARN] element not found: #${name}`);
});
// Show all keywords
btnAll?.addEventListener('click', async () => {
console.log('[click] btnAll');
const box = document.getElementById('allTerms');
box.innerHTML = `<div class="text-slate-600 text-sm">Loading all terms…</div>`;
try {
const data = await fetchJSON('/terms');
console.log('[data] /terms =', data);
renderAllTerms(data);
pushHistory('terms', '(all)');
} catch (e) {
console.error('[error] /terms', e);
box.innerHTML = `<div class="text-sm text-red-600">Load failed: ${escapeHTML(e.message)}</div>
<div class="text-xs text-slate-600 mt-1">請確認 <code>data/terms.json</code> 是否存在且能以瀏覽器開啟。</div>`;
}
});
// Related terms
btnRelated?.addEventListener('click', async () => {
console.log('[click] btnRelated');
const term = document.getElementById('termInput').value.trim();
if (!term) { document.getElementById('relatedOut').innerHTML = '<div class="text-sm text-slate-500">Please enter a term.</div>'; return; }
const path = `/terms/${encodeURIComponent(term)}`;
document.getElementById('urlTerms').innerHTML =
`GET <a class="text-brand-600 underline" target="_blank" href="${API_BASE}${path}">${API_BASE}${path}</a>`;
document.getElementById('relatedOut').innerHTML = spinner('Searching related terms…');
try {
const data = await fetchJSON(path);
console.log('[data]', path, data);
renderRelated(data);
pushHistory('related', term);
} catch (e) {
console.error('[error]', path, e);
document.getElementById('relatedOut').innerHTML =
`<div class="text-red-600 text-sm">${escapeHTML(e.message)}</div>`;
}
});
// Studies
btnStudies?.addEventListener('click', async () => {
console.log('[click] btnStudies');
const q = document.getElementById('studyInput').value.trim();
if (!q) { document.getElementById('studiesOut').innerHTML = '<div class="text-sm text-slate-500">Please enter a query.</div>'; return; }
const path = `/query/${encodeURIComponent(q)}/studies`;
document.getElementById('urlStudies').innerHTML =
`GET <a class="text-brand-600 underline" target="_blank" href="${API_BASE}${path}">${API_BASE}${path}</a>`;
document.getElementById('studiesOut').innerHTML = spinner('Searching studies…');
try {
const data = await fetchJSON(path);
console.log('[data]', path, data);
renderStudies(data);
pushHistory('studies', q);
} catch (e) {
console.error('[error]', path, e);
document.getElementById('studiesOut').innerHTML =
`<div class="text-red-600 text-sm">${escapeHTML(e.message)}</div>`;
}
});
// 小健檢:mock 檔是否可讀(只跑一次,不影響功能)
fetch('data/terms.json')
.then(r => { console.log('[check] data/terms.json status', r.status); })
.catch(err => console.warn('[check] cannot read data/terms.json', err));
});
</script>
接著請你做三件事:
強制重新整理頁面(Ctrl/Cmd + Shift + R),避免快取。
在 Console 觀察是否出現:
[NeuroSearch] script loaded …
[NeuroSearch] DOM ready
點按鈕時有 [click] btnAll / btnRelated / btnStudies
手動打開 http://127.0.0.1:5500/data/terms.json 看是否能顯示 JSON。
如果點按鈕有 [click] ... 但 UI 沒變,會在 Console 看到對應的錯誤訊息,我再幫你對症修。
如果連 [click] 都沒有,表示事件沒綁到(可能你正在預覽的 HTML 不是這個檔;或 script 區塊位置有誤)。此時把整個 <script> 放在 </body> 之前,或保留上面 DOMContentLoaded 包裹即可。
你現在「點了沒反應」是因為 JS 在載入時就語法錯,console 顯示:
Uncaught SyntaxError: Unexpected token '}' (at index.html:122:5)
一旦有這種錯,整個 <script> 都不會執行,所以按鈕事件也綁不上、完全沒 log。
最有效的救法:把整段 <script> 換成一個「確定可執行的版本」。下面是一份精簡、可用的腳本(含 mock、History 點擊重播、你要的按鈕色號),直接整段取代你檔案中的 <script>…</script>:
html<script>
// ===== Tailwind/色系已在 <head> 處;這裡只放 JS =====
const API_BASE = 'https://mil.psy.ntu.edu.tw:5000';
const HISTORY_KEY = 'neurosearch_history_v1';
const $ = (s) => document.querySelector(s);
const escapeHTML = (s) => String(s)
.replaceAll('&','&').replaceAll('<','<')
.replaceAll('>','>').replaceAll('"','"')
.replaceAll("'","'");
function badge(txt){
return `<span class="inline-flex items-center rounded-full bg-white text-slate-600 px-3 py-1 text-xs mr-2 mb-2">${escapeHTML(txt)}</span>`;
}
function spinner(msg='Loading…'){
return `<div class="flex items-center gap-3 text-slate-600 text-sm">
<svg class="animate-spin h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke-width="4"></circle>
<path class="opacity-75" d="M4 12a8 8 0 018-8" stroke-width="4" stroke-linecap="round"></path>
</svg>
<span>${escapeHTML(msg)}</span>
</div>`;
}
// ---------- History ----------
function pushHistory(type, q){
const now = new Date().toISOString().slice(0,19).replace('T',' ');
const arr = JSON.parse(localStorage.getItem(HISTORY_KEY) || '[]');
arr.unshift({ type, q, at: now });
localStorage.setItem(HISTORY_KEY, JSON.stringify(arr.slice(0,50)));
renderHistory();
}
function renderHistory(){
const box = $('#history');
const arr = JSON.parse(localStorage.getItem(HISTORY_KEY) || '[]');
if (arr.length === 0){
box.innerHTML = '<div class="text-slate-700 text-xs">(empty)</div>';
return;
}
box.innerHTML = arr.map(it => `
<div class="hist-item cursor-pointer rounded px-2 py-1 hover:bg-white/30"
data-type="${escapeHTML(it.type)}" data-q="${escapeHTML(it.q)}">
<div class="text-xs text-slate-600">${escapeHTML(it.at)} · ${escapeHTML(it.type)}</div>
<div class="text-sm">${badge(it.q)}</div>
</div>
`).join('');
}
// ---------- Fetch with timeout + mock fallback ----------
async function fetchJSON(path){
const url = `${API_BASE}${path}`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 10000);
try {
const resp = await fetch(url, { signal: controller.signal });
clearTimeout(timer);
if (!resp.ok) throw new Error(`HTTP ${resp.status} ${resp.statusText}`);
const ct = resp.headers.get('content-type') || '';
return ct.includes('application/json') ? resp.json() : resp.text();
} catch (err) {
// mock files (ensure /data/*.json 存在)
const mockMap = {
'/terms': 'data/terms.json',
'/terms/fear': 'data/terms_fear.json',
'/query/amygdala%20not%20emotion/studies': 'data/studies_amygdala_not_emotion.json',
};
if (mockMap[path]) {
const r = await fetch(mockMap[path]);
return r.json();
}
throw err;
}
}
// ---------- Renderers ----------
function renderAllTerms(list){
const box = $('#allTerms');
if (!list) { box.innerHTML = '<div class="text-sm text-red-600">No data returned.</div>'; return; }
if (Array.isArray(list)){
if (list.length === 0){ box.innerHTML = '<div class="text-sm text-slate-600">(no terms)</div>'; return; }
box.innerHTML = `<div class="flex flex-wrap">${list.map(badge).join('')}</div>`;
return;
}
box.innerHTML = `<pre class="text-xs">${escapeHTML(JSON.stringify(list,null,2))}</pre>`;
}
function renderRelated(obj){
const box = $('#relatedOut');
if (Array.isArray(obj)){ box.innerHTML = `<div class="flex flex-wrap">${obj.map(badge).join('')}</div>`; return; }
if (!obj || typeof obj !== 'object'){ box.innerHTML = `<pre class="text-sm">${escapeHTML(String(obj))}</pre>`; return; }
box.innerHTML = Object.entries(obj).map(([k, v]) => {
if (Array.isArray(v)){
return `<div class="mb-3">
<div class="text-sm font-semibold mb-1">${escapeHTML(k)}</div>
<div class="flex flex-wrap">${v.map(x => badge(String(x))).join('')}</div>
</div>`;
}
if (v && typeof v === 'object'){
const rows = Object.entries(v).sort((a,b)=>(b[1]||0)-(a[1]||0));
return `<div class="mb-3">
<div class="text-sm font-semibold mb-1">${escapeHTML(k)}</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
${rows.map(([term,score]) => `
<div class="flex items-center justify-between rounded-lg border p-2 text-sm">
<span class="truncate mr-2">${escapeHTML(term)}</span>
<span class="text-slate-500">${escapeHTML(String(score))}</span>
</div>`).join('')}
</div>
</div>`;
}
return `<pre class="text-xs">${escapeHTML(JSON.stringify(v,null,2))}</pre>`;
}).join('');
}
function renderStudies(rows){
const box = $('#studiesOut');
if (!Array.isArray(rows) || rows.length === 0){ box.innerHTML = '<div class="text-sm text-slate-500">No results.</div>'; return; }
const cols = Array.from(new Set(rows.flatMap(o => Object.keys(o))));
const head = cols.map(c => `<th class="px-3 py-2 text-left text-xs font-semibold bg-slate-50">${escapeHTML(c)}</th>`).join('');
const body = rows.map(r => `<tr>${cols.map(c => `<td class="px-3 py-2 border-t align-top text-sm">${escapeHTML(String(r[c] ?? ''))}</td>`).join('')}</tr>`).join('');
box.innerHTML = `<div class="overflow-x-auto">
<table class="min-w-full border rounded"><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>
</div>`;
}
// ---------- Wire-up after DOM ready ----------
document.addEventListener('DOMContentLoaded', () => {
console.log('[NeuroSearch] DOM ready');
// Show all keywords
$('#btnAll')?.addEventListener('click', async () => {
$('#allTerms').innerHTML = spinner('Loading all terms…');
try { const data = await fetchJSON('/terms'); renderAllTerms(data); pushHistory('terms', '(all)'); }
catch (e) { $('#allTerms').innerHTML = `<div class="text-red-500 text-sm">${escapeHTML(e.message)}</div>`; }
});
// Related terms
$('#btnRelated')?.addEventListener('click', async () => {
const term = $('#termInput').value.trim();
if (!term){ $('#relatedOut').innerHTML = '<div class="text-sm text-slate-500">Please enter a term.</div>'; return; }
const path = `/terms/${encodeURIComponent(term)}`;
$('#urlTerms').innerHTML = `GET <a class="text-brand-600 underline" target="_blank" href="${API_BASE}${path}">${API_BASE}${path}</a>`;
$('#relatedOut').innerHTML = spinner('Searching related terms…');
try { const data = await fetchJSON(path); renderRelated(data); pushHistory('related', term); }
catch (e) { $('#relatedOut').innerHTML = `<div class="text-red-600 text-sm">${escapeHTML(e.message)}</div>`; }
});
// Studies
$('#btnStudies')?.addEventListener('click', async () => {
const q = $('#studyInput').value.trim();
if (!q){ $('#studiesOut').innerHTML = '<div class="text-sm text-slate-500">Please enter a query.</div>'; return; }
const path = `/query/${encodeURIComponent(q)}/studies`;
$('#urlStudies').innerHTML = `GET <a class="text-brand-600 underline" target="_blank" href="${API_BASE}${path}">${API_BASE}${path}</a>`;
$('#studiesOut').innerHTML = spinner('Searching studies…');
try { const data = await fetchJSON(path); renderStudies(data); pushHistory('studies', q); }
catch (e) { $('#studiesOut').innerHTML = `<div class="text-red-600 text-sm">${escapeHTML(e.message)}</div>`; }
});
// History click-to-replay
$('#history').addEventListener('click', (e) => {
const el = e.target.closest('.hist-item'); if (!el) return;
const type = el.dataset.type, q = el.dataset.q;
if (type === 'terms') $('#btnAll').click();
else if (type === 'related'){ $('#termInput').value = q; $('#btnRelated').click(); }
else if (type === 'studies'){ $('#studyInput').value = q; $('#btnStudies').click(); }
});
$('#clearHistory').addEventListener('click', () => { localStorage.removeItem(HISTORY_KEY); renderHistory(); });
// 初始渲染歷史
renderHistory();
});
</script>
接著做這三步:
儲存後,強制重新整理(Ctrl/Cmd+Shift+R)。
Console 應該不再有 Unexpected token '}'。
點「Show all keywords」應會先顯示 Loading;若後端未開,請確保 data/terms.json 可透過瀏覽器打開(例如 http://127.0.0.1:5500/data/terms.json)。
如果還有紅字,請把 最新的第一行錯誤訊息 貼上來,我會再幫你對位置修正。
<span class="inline-flex items-center rounded-full bg-white text-slate-600 px-3 py-1 text-xs mr-2 mb-2">${escapeHTML(txt)}</span>;
}
function spinner(msg='Loading…'){
return <div class="flex items-center gap-3 text-slate-600 text-sm">
<svg class="animate-spin h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke-width="4"></circle>
<path class="opacity-75" d="M4 12a8 8 0 018-8" stroke-width="4" stroke-linecap="round"></path>
</svg>
<span>${escapeHTML(msg)}</span>
</div>
}
const HISTORY_KEY = 'neurosearch_history_v1';
function pushHistory(type, q){
const now = new Date().toISOString().slice(0,19).replace('T',' ');
const item = { type, q, at: now };
const arr = JSON.parse(localStorage.getItem(HISTORY_KEY) || '[]');
arr.unshift(item);
localStorage.setItem(HISTORY_KEY, JSON.stringify(arr.slice(0,50)));
renderHistory();
}
function renderHistory(){
const box = $('#history');
const arr = JSON.parse(localStorage.getItem(HISTORY_KEY) || '[]');
if (arr.length === 0){ box.innerHTML = '<div class="text-slate-700 text-xs">(empty)</div>'; return; }
box.innerHTML = arr.map(it =>
<div class="hist-item cursor-pointer rounded px-2 py-1 hover:bg-white/30" data-type="${escapeHTML(it.type)}" data-q="${escapeHTML(it.q)}">
<div class="text-xs text-slate-600">${escapeHTML(it.at)} · ${escapeHTML(it.type)}</div>
<div class="text-sm">${badge(it.q)}</div>
</div>
).join('');
}
box.innerHTML = arr.map(it => <div class="mb-2">
<div class="text-xs text-slate-300">${escapeHTML(it.at)} · ${escapeHTML(it.type)}</div>
<div class="text-sm">${badge(it.q)}</div>
</div>).join('');
$('#clearHistory').addEventListener('click', () => { localStorage.removeItem(HISTORY_KEY); renderHistory(); });
// Click-to-replay on history
$('#history').addEventListener('click', (e) => {
const el = e.target.closest('.hist-item');
if (!el) return;
const type = el.dataset.type;
const q = el.dataset.q;
if (type === 'terms') {
document.getElementById('btnAll').click();
} else if (type === 'related') {
$('#termInput').value = q;
document.getElementById('btnRelated').click();
} else if (type === 'studies') {
$('#studyInput').value = q;
document.getElementById('btnStudies').click();
}
}); renderHistory();
async function fetchJSON(path){
const url = ${API_BASE}${path};
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 10000);
try {
const resp = await fetch(url, { signal: controller.signal });
clearTimeout(timer);
if (!resp.ok) throw new Error(HTTP ${resp.status} ${resp.statusText});
const ct = resp.headers.get('content-type') || '';
return ct.includes('application/json') ? resp.json() : resp.text();
} catch (err) {
const mockMap = {
'/terms': 'data/terms.json',
'/terms/fear': 'data/terms_fear.json',
'/query/amygdala%20not%20emotion/studies': 'data/studies_amygdala_not_emotion.json'
};
if (mockMap[path]) {
const r = await fetch(mockMap[path]);
return r.json();
}
throw err;
}
}
function renderAllTerms(list){
const box = $('#allTerms');
if (!Array.isArray(list)) { box.innerHTML = <pre class="text-xs">${escapeHTML(JSON.stringify(list,null,2))}</pre>; return; }
if (list.length === 0) { box.innerHTML = '<div class="text-xs text-slate-300">(no terms)</div>'; return; }
box.innerHTML = <div class="flex flex-wrap">${list.map(badge).join('')}</div>;
}
function renderRelated(obj){
const box = $('#relatedOut');
if (Array.isArray(obj)) { box.innerHTML = <div class="flex flex-wrap">${obj.map(badge).join('')}</div>; return; }
if (!obj || typeof obj !== 'object') { box.innerHTML = <pre class="text-sm">${escapeHTML(String(obj))}</pre>; return; }
box.innerHTML = Object.entries(obj).map(([k, v]) => {
if (Array.isArray(v)) {
return <div class="mb-3">
<div class="text-sm font-semibold mb-1">${escapeHTML(k)}</div>
<div class="flex flex-wrap">${v.map(x => badge(String(x))).join('')}</div>
</div>
}
if (v && typeof v === 'object') {
const rows = Object.entries(v).sort((a,b)=> (b[1]||0) - (a[1]||0));
return <div class="mb-3">
<div class="text-sm font-semibold mb-1">${escapeHTML(k)}</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
${rows.map(([term,score]) => <div class="flex items-center justify-between rounded-lg border p-2 text-sm">
<span class="truncate mr-2">${escapeHTML(term)}</span>
<span class="text-slate-500">${escapeHTML(String(score))}</span>
</div>).join('')}
</div>
</div>
}
return <pre class="text-xs">${escapeHTML(JSON.stringify(v,null,2))}</pre>
}).join('');
}
function renderStudies(rows){
const box = $('#studiesOut');
if (!Array.isArray(rows) || rows.length === 0) { box.innerHTML = '<div class="text-sm text-slate-500">No results.</div>'; return; }
const cols = Array.from(new Set(rows.flatMap(o => Object.keys(o))));
const head = cols.map(c => <th class="px-3 py-2 text-left text-xs font-semibold bg-slate-50">${escapeHTML(c)}</th>).join('');
const body = rows.map(r => <tr>${cols.map(c => <td class="px-3 py-2 border-t align-top text-sm">${escapeHTML(String(r[c] ?? ''))}</td>).join('')}</tr>).join('');
box.innerHTML = <div class="overflow-x-auto">
<table class="min-w-full border rounded">
<thead><tr>${head}</tr></thead>
<tbody>${body}</tbody>
</table>
</div>;
}
$('#btnAll').addEventListener('click', async () => {
$('#allTerms').innerHTML = spinner('Loading all terms…');
try { const data = await fetchJSON('/terms'); renderAllTerms(data); pushHistory('terms', '(all)'); }
catch (e) { $('#allTerms').innerHTML = <div class="text-red-500 text-sm">${escapeHTML(e.message)}</div>; }
});
$('#btnRelated').addEventListener('click', async () => {
const term = $('#termInput').value.trim();
if (!term) { $('#relatedOut').innerHTML = '<div class="text-sm text-slate-500">Please enter a term.</div>'; return; }
const path = /terms/${encodeURIComponent(term)};
$('#urlTerms').innerHTML = GET <a class="text-brand-600 underline" target="_blank" href="${API_BASE}${path}">${API_BASE}${path}</a>;
$('#relatedOut').innerHTML = spinner('Searching related terms…');
try { const data = await fetchJSON(path); renderRelated(data); pushHistory('related', term); }
catch (e) { $('#relatedOut').innerHTML = <div class="text-red-600 text-sm">${escapeHTML(e.message)}</div>; }
});
$('#btnStudies').addEventListener('click', async () => {
const q = $('#studyInput').value.trim();
if (!q) { $('#studiesOut').innerHTML = '<div class="text-sm text-slate-500">Please enter a query.</div>'; return; }
const path = /query/${encodeURIComponent(q)}/studies;
$('#urlStudies').innerHTML = GET <a class="text-brand-600 underline" target="_blank" href="${API_BASE}${path}">${API_BASE}${path}</a>;
$('#studiesOut').innerHTML = spinner('Searching studies…');
try { const data = await fetchJSON(path); renderStudies(data); pushHistory('studies', q); }
catch (e) { $('#studiesOut').innerHTML = <div class="text-red-600 text-sm">${escapeHTML(e.message)}</div>; }
});
renderHistory();
</script>
<body class="min-h-screen bg-slate-50 text-slate-800">
<div class="grid grid-cols-1 lg:grid-cols-[280px_1fr] min-h-screen">
<aside class="bg-slate-900 text-slate-100 p-4 lg:p-6 space-y-6">
<h2 class="text-lg font-semibold tracking-tight">Explore neuroscience<br>Research.</h2>
<div class="space-y-2">
<button id="btnAll" class="w-full rounded-lg bg-brand-500 hover:bg-brand-600 text-white px-4 py-2">Show all keywords</button>
<div id="allTerms" class="h-56 overflow-auto rounded-lg bg-white/40 backdrop-blur border border-white/20 p-3 text-sm text-slate-800"></div>
</div>
<div class="pt-4 border-t border-slate-700/60 space-y-2">
<div class="flex items-center justify-between">
<h3 class="font-medium">History</h3>
<button id="clearHistory" class="text-xs text-slate-300 hover:underline">Clear</button>
</div>
<div id="history" class="h-40 overflow-auto rounded-lg bg-white/40 backdrop-blur border border-white/20 p-3 text-sm text-slate-800"></div>
</div>
</aside>
<main class="p-4 lg:p-8 space-y-8">
<section class="space-y-3">
<div class="flex items-center justify-between flex-wrap gap-2">
<h2 class="text-xl font-semibold">Search for associated terms</h2>
<div class="text-xs text-slate-500">Show recommended searching keyword as gray text here.</div>
</div>
<div class="flex gap-2">
<input id="termInput" class="flex-1 rounded-lg border px-3 py-2" placeholder="e.g., amygdala" />
<button id="btnRelated" class="rounded-lg bg-brand-500 hover:bg-brand-600 text-white px-4 py-2">Search</button>
</div>
<div id="urlTerms" class="text-sm text-slate-500"></div>
<div class="rounded-2xl bg-white border p-4 min-h-[9rem]">
<div class="text-xs text-slate-500 mb-2">Show scrollable result here • co‑occur rate & word</div>
<div id="relatedOut" class="max-h-64 overflow-auto"></div>
</div>
</section>
<section class="space-y-3">
<div class="flex items-center justify-between flex-wrap gap-2">
<h2 class="text-xl font-semibold">Search for studies</h2>
<div class="text-xs text-slate-500">Hint: Use <code>and</code>, <code>not</code> for logical search</div>
</div>
<div class="flex gap-2">
<input id="studyInput" class="flex-1 rounded-lg border px-3 py-2" placeholder="e.g., amygdala not emotion" />
<button id="btnStudies" class="rounded-lg bg-brand-500 hover:bg-brand-600 text-white px-4 py-2">Search</button>
</div>
<div id="urlStudies" class="text-sm text-slate-500"></div>
<div class="rounded-2xl bg-white border p-4 min-h-[9rem]">
<div class="text-xs text-slate-500 mb-2">Show scrollable research here (organize as table)</div>
<div id="studiesOut" class="max-h-[28rem] overflow-auto"></div>
</div>
</section>
</main>
</div>
</body>
</html>
目前長這樣 你現在的檔案有兩個關鍵問題,所以按鈕都沒綁到:
你的 <script> 在 </head> 後、<body> 之前就執行了 → DOM 尚未生成,getElementById(...) 會拿到 null,事件綁定失敗。
在 function renderHistory(){...} 之後有一段多餘的程式碼(box.innerHTML = arr.map(... 那幾行),它在函式外、而且用了不存在的變數 box、arr,會在執行期出錯並中斷後續程式。
renderHistory() 後面)把下面這段整塊刪掉(它不應該存在):
js box.innerHTML = arr.map(it => `<div class="mb-2">
<div class="text-xs text-slate-300">${escapeHTML(it.at)} · ${escapeHTML(it.type)}</div>
<div class="text-sm">${badge(it.q)}</div>
</div>`).join('');
$('#clearHistory').addEventListener('click', () => { localStorage.removeItem(HISTORY_KEY); renderHistory(); });
// Click-to-replay on history
$('#history').addEventListener('click', (e) => {
const el = e.target.closest('.hist-item');
if (!el) return;
const type = el.dataset.type;
const q = el.dataset.q;
if (type === 'terms') {
document.getElementById('btnAll').click();
} else if (type === 'related') {
$('#termInput').value = q;
document.getElementById('btnRelated').click();
} else if (type === 'studies') {
$('#studyInput').value = q;
document.getElementById('btnStudies').click();
}
}); renderHistory();
你可以選其中一種做法:
做法 1(推薦):把整個 <script> ... </script> 移到 </body> 之前。
做法 2:用 DOMContentLoaded 包起來(保留 script 在 head 也行)。
以下給你修正好的事件綁定區塊(做法 2)。把你原本三個 addEventListener、history 點擊&清除那幾段,全部換成這段即可:
jsdocument.addEventListener('DOMContentLoaded', () => {
// Show all keywords
document.getElementById('btnAll')?.addEventListener('click', async () => {
document.getElementById('allTerms').innerHTML = spinner('Loading all terms…');
try {
const data = await fetchJSON('/terms');
renderAllTerms(data);
pushHistory('terms', '(all)');
} catch (e) {
document.getElementById('allTerms').innerHTML =
`<div class="text-red-500 text-sm">${escapeHTML(e.message)}</div>`;
}
});
// Related terms
document.getElementById('btnRelated')?.addEventListener('click', async () => {
const term = document.getElementById('termInput').value.trim();
if (!term) {
document.getElementById('relatedOut').innerHTML =
'<div class="text-sm text-slate-500">Please enter a term.</div>';
return;
}
const path = `/terms/${encodeURIComponent(term)}`;
document.getElementById('urlTerms').innerHTML =
`GET <a class="text-brand-600 underline" target="_blank" href="${API_BASE}${path}">${API_BASE}${path}</a>`;
document.getElementById('relatedOut').innerHTML = spinner('Searching related terms…');
try {
const data = await fetchJSON(path);
renderRelated(data);
pushHistory('related', term);
} catch (e) {
document.getElementById('relatedOut').innerHTML =
`<div class="text-red-600 text-sm">${escapeHTML(e.message)}</div>`;
}
});
// Studies
document.getElementById('btnStudies')?.addEventListener('click', async () => {
const q = document.getElementById('studyInput').value.trim();
if (!q) {
document.getElementById('studiesOut').innerHTML =
'<div class="text-sm text-slate-500">Please enter a query.</div>';
return;
}
const path = `/query/${encodeURIComponent(q)}/studies`;
document.getElementById('urlStudies').innerHTML =
`GET <a class="text-brand-600 underline" target="_blank" href="${API_BASE}${path}">${API_BASE}${path}</a>`;
document.getElementById('studiesOut').innerHTML = spinner('Searching studies…');
try {
const data = await fetchJSON(path);
renderStudies(data);
pushHistory('studies', q);
} catch (e) {
document.getElementById('studiesOut').innerHTML =
`<div class="text-red-600 text-sm">${escapeHTML(e.message)}</div>`;
}
});
// History replay & clear
document.getElementById('history').addEventListener('click', (e) => {
const el = e.target.closest('.hist-item'); if (!el) return;
const type = el.dataset.type, q = el.dataset.q;
if (type === 'terms') document.getElementById('btnAll').click();
else if (type === 'related'){ document.getElementById('termInput').value = q; document.getElementById('btnRelated').click(); }
else if (type === 'studies'){ document.getElementById('studyInput').value = q; document.getElementById('btnStudies').click(); }
});
document.getElementById('clearHistory').addEventListener('click', () => {
localStorage.removeItem(HISTORY_KEY);
renderHistory();
});
// 初始渲染歷史
renderHistory();
});
強制重整(Ctrl/Cmd + Shift + R)。
Console 不應再有 Unexpected token 類錯誤。
按鈕點一下,應該至少會先看到 Loading…。
如果老師的後端還沒開,請確認 data/terms.json 等 mock 檔存在並能用 http://127.0.0.1:5500/data/terms.json 開啟。
需要我直接把你整份檔案「整理成正確順序」(把 <script> 移到 </body> 前、刪掉多餘片段)再貼回給你嗎?
下面是一份乾淨、可直接取代的 index.html(Tailwind + AJAX,含 mock fallback、History 點擊重播、按鈕色 #76AEA2,以及側欄灰白 40% 不透明背景)。把它整份貼上即可:
html<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Explore Neuroscience Research · Tailwind + AJAX</title>
<!-- Tailwind (CDN) -->
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
brand: { 50: '#eff6ff', 500: '#76AEA2', 600: '#5E8C84' }
}
}
}
};
</script>
</head>
<body class="min-h-screen bg-slate-50 text-slate-800">
<div class="grid grid-cols-1 lg:grid-cols-[280px_1fr] min-h-screen">
<!-- Sidebar -->
<aside class="bg-slate-900 text-slate-100 p-4 lg:p-6 space-y-6">
<h2 class="text-lg font-semibold tracking-tight">Explore neuroscience<br>Research.</h2>
<div class="space-y-2">
<button id="btnAll" class="w-full rounded-lg bg-brand-500 hover:bg-brand-600 text-white px-4 py-2">
Show all keywords
</button>
<!-- clearer card background -->
<div id="allTerms"
class="h-56 overflow-auto rounded-lg bg-white/40 backdrop-blur border border-white/20 p-3 text-sm text-slate-800"></div>
</div>
<div class="pt-4 border-t border-slate-700/60 space-y-2">
<div class="flex items-center justify-between">
<h3 class="font-medium">History</h3>
<button id="clearHistory" class="text-xs text-slate-300 hover:underline">Clear</button>
</div>
<!-- clearer card background -->
<div id="history"
class="h-40 overflow-auto rounded-lg bg-white/40 backdrop-blur border border-white/20 p-3 text-sm text-slate-800"></div>
</div>
</aside>
<!-- Main -->
<main class="p-4 lg:p-8 space-y-8">
<!-- Associated terms -->
<section class="space-y-3">
<div class="flex items-center justify-between flex-wrap gap-2">
<h2 class="text-xl font-semibold">Search for associated terms</h2>
<div class="text-xs text-slate-500">Show recommended searching keyword as gray text here.</div>
</div>
<div class="flex gap-2">
<input id="termInput" class="flex-1 rounded-lg border px-3 py-2" placeholder="e.g., amygdala" />
<button id="btnRelated" class="rounded-lg bg-brand-500 hover:bg-brand-600 text-white px-4 py-2">Search</button>
</div>
<div id="urlTerms" class="text-sm text-slate-500"></div>
<div class="rounded-2xl bg-white border p-4 min-h-[9rem]">
<div class="text-xs text-slate-500 mb-2">Show scrollable result here • co-occur rate & word</div>
<div id="relatedOut" class="max-h-64 overflow-auto"></div>
</div>
</section>
<!-- Studies -->
<section class="space-y-3">
<div class="flex items-center justify-between flex-wrap gap-2">
<h2 class="text-xl font-semibold">Search for studies</h2>
<div class="text-xs text-slate-500">Hint: Use <code>and</code>, <code>not</code> for logical search</div>
</div>
<div class="flex gap-2">
<input id="studyInput" class="flex-1 rounded-lg border px-3 py-2" placeholder="e.g., amygdala not emotion" />
<button id="btnStudies" class="rounded-lg bg-brand-500 hover:bg-brand-600 text-white px-4 py-2">Search</button>
</div>
<div id="urlStudies" class="text-sm text-slate-500"></div>
<div class="rounded-2xl bg-white border p-4 min-h-[9rem]">
<div class="text-xs text-slate-500 mb-2">Show scrollable research here (organize as table)</div>
<div id="studiesOut" class="max-h-[28rem] overflow-auto"></div>
</div>
</section>
</main>
</div>
<!-- ========= Scripts ========= -->
<script>
const API_BASE = 'https://mil.psy.ntu.edu.tw:5000';
const HISTORY_KEY = 'neurosearch_history_v1';
const $ = (s) => document.querySelector(s);
const escapeHTML = (s) => String(s)
.replaceAll('&','&').replaceAll('<','<')
.replaceAll('>','>').replaceAll('"','"').replaceAll("'","'");
function badge(txt){
return `<span class="inline-flex items-center rounded-full bg-white text-slate-600 px-3 py-1 text-xs mr-2 mb-2">${escapeHTML(txt)}</span>`;
}
function spinner(msg='Loading…'){
return `<div class="flex items-center gap-3 text-slate-600 text-sm">
<svg class="animate-spin h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke-width="4"></circle>
<path class="opacity-75" d="M4 12a8 8 0 018-8" stroke-width="4" stroke-linecap="round"></path>
</svg>
<span>${escapeHTML(msg)}</span>
</div>`;
}
// -------- History (with click-to-replay) --------
function pushHistory(type, q){
const now = new Date().toISOString().slice(0,19).replace('T',' ');
const arr = JSON.parse(localStorage.getItem(HISTORY_KEY) || '[]');
arr.unshift({ type, q, at: now });
localStorage.setItem(HISTORY_KEY, JSON.stringify(arr.slice(0,50)));
renderHistory();
}
function renderHistory(){
const box = $('#history');
const arr = JSON.parse(localStorage.getItem(HISTORY_KEY) || '[]');
if (arr.length === 0){
box.innerHTML = '<div class="text-slate-700 text-xs">(empty)</div>';
return;
}
box.innerHTML = arr.map(it => `
<div class="hist-item cursor-pointer rounded px-2 py-1 hover:bg-white/30"
data-type="${escapeHTML(it.type)}" data-q="${escapeHTML(it.q)}">
<div class="text-xs text-slate-600">${escapeHTML(it.at)} · ${escapeHTML(it.type)}</div>
<div class="text-sm">${badge(it.q)}</div>
</div>
`).join('');
}
// -------- fetch with timeout + mock fallback --------
async function fetchJSON(path){
const url = `${API_BASE}${path}`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 10000);
try {
const resp = await fetch(url, { signal: controller.signal });
clearTimeout(timer);
if (!resp.ok) throw new Error(`HTTP ${resp.status} ${resp.statusText}`);
const ct = resp.headers.get('content-type') || '';
return ct.includes('application/json') ? resp.json() : resp.text();
} catch (err) {
// Local mock files (create these under /data/)
const mockMap = {
'/terms': 'data/terms.json',
'/terms/fear': 'data/terms_fear.json',
'/query/amygdala%20not%20emotion/studies': 'data/studies_amygdala_not_emotion.json'
};
if (mockMap[path]) {
const r = await fetch(mockMap[path]);
return r.json();
}
throw err;
}
}
// -------- Renderers --------
function renderAllTerms(list){
const box = $('#allTerms');
if (!list) { box.innerHTML = '<div class="text-sm text-red-600">No data returned.</div>'; return; }
if (Array.isArray(list)){
if (list.length === 0) { box.innerHTML = '<div class="text-sm text-slate-600">(no terms)</div>'; return; }
box.innerHTML = `<div class="flex flex-wrap">${list.map(badge).join('')}</div>`;
return;
}
box.innerHTML = `<pre class="text-xs">${escapeHTML(JSON.stringify(list,null,2))}</pre>`;
}
function renderRelated(obj){
const box = $('#relatedOut');
if (Array.isArray(obj)) { box.innerHTML = `<div class="flex flex-wrap">${obj.map(badge).join('')}</div>`; return; }
if (!obj || typeof obj !== 'object') { box.innerHTML = `<pre class="text-sm">${escapeHTML(String(obj))}</pre>`; return; }
box.innerHTML = Object.entries(obj).map(([k, v]) => {
if (Array.isArray(v)) {
return `<div class="mb-3">
<div class="text-sm font-semibold mb-1">${escapeHTML(k)}</div>
<div class="flex flex-wrap">${v.map(x => badge(String(x))).join('')}</div>
</div>`;
}
if (v && typeof v === 'object') {
const rows = Object.entries(v).sort((a,b)=> (b[1]||0) - (a[1]||0));
return `<div class="mb-3">
<div class="text-sm font-semibold mb-1">${escapeHTML(k)}</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
${rows.map(([term,score]) => `
<div class="flex items-center justify-between rounded-lg border p-2 text-sm">
<span class="truncate mr-2">${escapeHTML(term)}</span>
<span class="text-slate-500">${escapeHTML(String(score))}</span>
</div>`).join('')}
</div>
</div>`;
}
return `<pre class="text-xs">${escapeHTML(JSON.stringify(v,null,2))}</pre>`;
}).join('');
}
function renderStudies(rows){
const box = $('#studiesOut');
if (!Array.isArray(rows) || rows.length === 0) { box.innerHTML = '<div class="text-sm text-slate-500">No results.</div>'; return; }
const cols = Array.from(new Set(rows.flatMap(o => Object.keys(o))));
const head = cols.map(c => `<th class="px-3 py-2 text-left text-xs font-semibold bg-slate-50">${escapeHTML(c)}</th>`).join('');
const body = rows.map(r => `<tr>${cols.map(c => `<td class="px-3 py-2 border-t align-top text-sm">${escapeHTML(String(r[c] ?? ''))}</td>`).join('')}</tr>`).join('');
box.innerHTML = `<div class="overflow-x-auto">
<table class="min-w-full border rounded">
<thead><tr>${head}</tr></thead>
<tbody>${body}</tbody>
</table>
</div>`;
}
// -------- Wire-up after DOM ready --------
document.addEventListener('DOMContentLoaded', () => {
// buttons
$('#btnAll')?.addEventListener('click', async () => {
$('#allTerms').innerHTML = spinner('Loading all terms…');
try { const data = await fetchJSON('/terms'); renderAllTerms(data); pushHistory('terms', '(all)'); }
catch (e) { $('#allTerms').innerHTML = `<div class="text-red-500 text-sm">${escapeHTML(e.message)}</div>`; }
});
$('#btnRelated')?.addEventListener('click', async () => {
const term = $('#termInput').value.trim();
if (!term) { $('#relatedOut').innerHTML = '<div class="text-sm text-slate-500">Please enter a term.</div>'; return; }
const path = `/terms/${encodeURIComponent(term)}`;
$('#urlTerms').innerHTML = `GET <a class="text-brand-600 underline" target="_blank" href="${API_BASE}${path}">${API_BASE}${path}</a>`;
$('#relatedOut').innerHTML = spinner('Searching related terms…');
try { const data = await fetchJSON(path); renderRelated(data); pushHistory('related', term); }
catch (e) { $('#relatedOut').innerHTML = `<div class="text-red-600 text-sm">${escapeHTML(e.message)}</div>`; }
});
$('#btnStudies')?.addEventListener('click', async () => {
const q = $('#studyInput').value.trim();
if (!q) { $('#studiesOut').innerHTML = '<div class="text-sm text-slate-500">Please enter a query.</div>'; return; }
const path = `/query/${encodeURIComponent(q)}/studies`;
$('#urlStudies').innerHTML = `GET <a class="text-brand-600 underline" target="_blank" href="${API_BASE}${path}">${API_BASE}${path}</a>`;
$('#studiesOut').innerHTML = spinner('Searching studies…');
try { const data = await fetchJSON(path); renderStudies(data); pushHistory('studies', q); }
catch (e) { $('#studiesOut').innerHTML = `<div class="text-red-600 text-sm">${escapeHTML(e.message)}</div>`; }
});
// history
$('#history').addEventListener('click', (e) => {
const el = e.target.closest('.hist-item'); if (!el) return;
const type = el.dataset.type, q = el.dataset.q;
if (type === 'terms') $('#btnAll').click();
else if (type === 'related'){ $('#termInput').value = q; $('#btnRelated').click(); }
else if (type === 'studies'){ $('#studyInput').value = q; $('#btnStudies').click(); }
});
$('#clearHistory').addEventListener('click', () => { localStorage.removeItem(HISTORY_KEY); renderHistory(); });
// init
renderHistory();
});
</script>
</body>
</html>
若要在後端未開時可測試,請在專案根目錄新增
data/資料夾與以下檔案:
data/terms.json(例如["amygdala","fear","emotion","moral"])
data/terms_fear.json
data/studies_amygdala_not_emotion.json
有需要我一併提供三個 mock JSON 範例檔內容嗎?
超清楚!你現在接到的新伺服器回傳格式是:
/terms:{ "terms": [ "able", "abstract", ... ] }
/terms/<term>:{ "related": [ { "term": "...", "co_count": 726, "jaccard": 0.283... }, ... ] }
/query/<q>/studies:{ "applied": {...}, "count": 292, "results": [ { "id": "...", "study_id": "...", "title": "...", "authors": "...", "journal": "...", "year": 1999.0 }, ... ] }
所以需要三個修正:
改 API 主機到 https://hpc.psy.ntu.edu.tw:5000
/terms/<term> 用表格顯示三欄(Term / Co-count / Jaccard)
/terms 與 /studies 容忍新結構(抓 data.terms、data.results),studies 也用表格顯示
把你檔案中的這幾段 替換 掉就行(其餘不動):
jsconst API_BASE = 'https://hpc.psy.ntu.edu.tw:5000';
{terms: [...]} 結構jsfunction renderAllTerms(data){
const box = $('#allTerms');
// 轉成陣列
const list = Array.isArray(data) ? data
: (data && Array.isArray(data.terms) ? data.terms : []);
if (list.length === 0){
box.innerHTML = '<div class="text-sm text-slate-600">(no terms)</div>';
return;
}
box.innerHTML = `<div class="flex flex-wrap">${list.map(badge).join('')}</div>`;
}
jsfunction renderRelated(data){
const box = $('#relatedOut');
const arr = Array.isArray(data) ? data
: (data && Array.isArray(data.related) ? data.related : null);
if (!arr){
// 後備:不認得的結構就原樣印出
box.innerHTML = `<pre class="text-xs">${escapeHTML(JSON.stringify(data,null,2))}</pre>`;
return;
}
if (arr.length === 0){
box.innerHTML = '<div class="text-sm text-slate-500">No related terms.</div>';
return;
}
const head = `
<tr>
<th class="px-3 py-2 text-left text-xs font-semibold bg-slate-50">Term</th>
<th class="px-3 py-2 text-left text-xs font-semibold bg-slate-50">Co-count</th>
<th class="px-3 py-2 text-left text-xs font-semibold bg-slate-50">Jaccard</th>
</tr>`;
const body = arr.map(r => `
<tr>
<td class="px-3 py-2 border-t text-sm">${escapeHTML(r.term ?? '')}</td>
<td class="px-3 py-2 border-t text-sm">${escapeHTML(String(r.co_count ?? ''))}</td>
<td class="px-3 py-2 border-t text-sm">${(typeof r.jaccard === 'number' ? r.jaccard.toFixed(3) : escapeHTML(String(r.jaccard ?? '')))}</td>
</tr>`).join('');
box.innerHTML = `
<div class="overflow-x-auto">
<table class="min-w-full border rounded">
<thead>${head}</thead>
<tbody>${body}</tbody>
</table>
</div>`;
}
data.results,表格顯示jsfunction renderStudies(data){
const box = $('#studiesOut');
const rows = Array.isArray(data) ? data
: (data && Array.isArray(data.results) ? data.results : []);
const total = (data && typeof data.count === 'number') ? data.count : rows.length;
if (!rows || rows.length === 0){
box.innerHTML = '<div class="text-sm text-slate-500">No results.</div>';
return;
}
// 選擇要顯示的欄位順序
const cols = ['id','study_id','year','title','authors','journal'];
const head = cols.map(c => `<th class="px-3 py-2 text-left text-xs font-semibold bg-slate-50">${escapeHTML(c)}</th>`).join('');
const body = rows.map(r => `
<tr>
${cols.map(c => `<td class="px-3 py-2 border-t align-top text-sm">${escapeHTML(String(r[c] ?? ''))}</td>`).join('')}
</tr>`).join('');
box.innerHTML = `
<div class="text-xs text-slate-500 mb-2">Count: ${escapeHTML(String(total))}</div>
<div class="overflow-x-auto">
<table class="min-w-full border rounded">
<thead><tr>${head}</tr></thead>
<tbody>${body}</tbody>
</table>
</div>`;
}
把 mockMap 的 key 換到新 host 也會命中一樣的 path,所以不用改;只要確保 data/ 仍在專案根目錄即可。
/query/.../studies 其實有資料,但回傳是物件 { count, results:[...] },你原本的 renderStudies 只接受「純陣列」,所以顯示空。上面已修好。
/terms/<term> 回傳的是 related 的物件陣列,你原本用 badge 直接渲染,會變 [object Object]。上面改為表格了。
套上面 1–4 段後,重新整理(Ctrl/Cmd+Shift+R),再測三條:
/terms → 左側 chips
/terms/amygdala → 右側 三欄表格
/query/amygdala not emotion/studies → 右側 研究表格 + count
如果還想把 related 的表格加上「點 term 直接觸發下一次 /terms/<term> 查詢」的互動,我也可以幫你把每一列變成可點擊的高亮列。
看起來是「後端回來的 Content-Type 並非 application/json」,所以 fetchJSON() 走了 resp.text() 或直接當成字串,導致 renderStudies() 收到 string 而不是物件,最後就判定成「No results.」
直接把 fetchJSON() 改成「一律先拿 text,再嘗試 JSON.parse」即可,同時保留 mock fallback。還有在 renderAllTerms / renderRelated / renderStudies 裡,加一行:若拿到的是字串就先 JSON.parse。
把下面兩段貼到你的檔案,覆蓋原本的函式就好(其他不動):
fetchJSONjsasync function fetchJSON(path){
const url = `${API_BASE}${path}`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 10000);
try {
const resp = await fetch(url, { signal: controller.signal });
clearTimeout(timer);
if (!resp.ok) throw new Error(`HTTP ${resp.status} ${resp.statusText}`);
// 先拿 text,再嘗試 parse 成 JSON(避免 Content-Type 不是 application/json)
const raw = await resp.text();
try { return JSON.parse(raw); } catch { return raw; }
} catch (err) {
// mock fallback
const mockMap = {
'/terms': 'data/terms.json',
'/terms/fear': 'data/terms_fear.json',
'/query/amygdala%20not%20emotion/studies': 'data/studies_amygdala_not_emotion.json'
};
if (mockMap[path]) {
const r = await fetch(mockMap[path]);
const t = await r.text();
try { return JSON.parse(t); } catch { return t; }
}
throw err;
}
}
jsfunction toJSON(data){
if (typeof data === 'string') {
try { return JSON.parse(data); } catch { /* 不是 JSON 就原樣回傳字串 */ }
}
return data;
}
// /terms
function renderAllTerms(data){
data = toJSON(data);
const box = $('#allTerms');
const list = Array.isArray(data) ? data
: (data && Array.isArray(data.terms) ? data.terms : []);
if (list.length === 0){
box.innerHTML = '<div class="text-sm text-slate-600">(no terms)</div>';
return;
}
box.innerHTML = `<div class="flex flex-wrap">${list.map(badge).join('')}</div>`;
}
// /terms/<term>
function renderRelated(data){
data = toJSON(data);
const box = $('#relatedOut');
const arr = Array.isArray(data) ? data
: (data && Array.isArray(data.related) ? data.related : null);
if (!arr){
box.innerHTML = `<pre class="text-xs">${escapeHTML(typeof data === 'string' ? data : JSON.stringify(data,null,2))}</pre>`;
return;
}
if (arr.length === 0){
box.innerHTML = '<div class="text-sm text-slate-500">No related terms.</div>';
return;
}
const head = `
<tr>
<th class="px-3 py-2 text-left text-xs font-semibold bg-slate-50">Term</th>
<th class="px-3 py-2 text-left text-xs font-semibold bg-slate-50">Co-count</th>
<th class="px-3 py-2 text-left text-xs font-semibold bg-slate-50">Jaccard</th>
</tr>`;
const body = arr.map(r => `
<tr>
<td class="px-3 py-2 border-t text-sm">${escapeHTML(r.term ?? '')}</td>
<td class="px-3 py-2 border-t text-sm">${escapeHTML(String(r.co_count ?? ''))}</td>
<td class="px-3 py-2 border-t text-sm">${(typeof r.jaccard === 'number' ? r.jaccard.toFixed(3) : escapeHTML(String(r.jaccard ?? '')))}</td>
</tr>`).join('');
box.innerHTML = `
<div class="overflow-x-auto">
<table class="min-w-full border rounded">
<thead>${head}</thead>
<tbody>${body}</tbody>
</table>
</div>`;
}
// /query/<q>/studies
function renderStudies(data){
data = toJSON(data);
const box = $('#studiesOut');
const rows = Array.isArray(data) ? data
: (data && Array.isArray(data.results) ? data.results : []);
const total = (data && typeof data.count === 'number') ? data.count : rows.length;
if (!rows || rows.length === 0){
box.innerHTML = '<div class="text-sm text-slate-500">No results.</div>';
return;
}
const cols = ['id','study_id','year','title','authors','journal'];
const head = cols.map(c => `<th class="px-3 py-2 text-left text-xs font-semibold bg-slate-50">${escapeHTML(c)}</th>`).join('');
const body = rows.map(r => `
<tr>
${cols.map(c => `<td class="px-3 py-2 border-t align-top text-sm">${escapeHTML(String(r[c] ?? ''))}</td>`).join('')}
</tr>`).join('');
box.innerHTML = `
<div class="text-xs text-slate-500 mb-2">Count: ${escapeHTML(String(total))}</div>
<div class="overflow-x-auto">
<table class="min-w-full border rounded">
<thead><tr>${head}</tr></thead>
<tbody>${body}</tbody>
</table>
</div>`;
}
jsconst API_BASE = 'https://hpc.psy.ntu.edu.tw:5000';
完成後,強制重整(Ctrl/Cmd + Shift + R),再測這三條:
/terms → 左側 chips(從 {terms:[...]} 取資料)
/terms/amygdala → 右側表格(三欄:term / co-count / jaccard)
/query/amygdala not emotion/studies → 右側表格(含 Count)
若仍不行,請開 Console 看看 console.log(typeof data, data)(你也可以暫時在 renderStudies 的開頭加這行),把第一筆 log 貼給我,我就能精準判斷還差哪一步。
會,只「studies」拿不到很常見:通常是回傳內容被當成「純字串」了,所以 renderStudies 看不到 results 陣列 → 判定成 No results。直接把下面兩段換上去(更強壯的解析+把問題直接印到畫面),其他程式不動。
fetchJSON 換成這個(先拿 text,再穩健地 JSON.parse)jsasync function fetchJSON(path){
const url = `${API_BASE}${path}`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 15000);
try {
const resp = await fetch(url, { signal: controller.signal, credentials: "omit" });
clearTimeout(timer);
if (!resp.ok) throw new Error(`HTTP ${resp.status} ${resp.statusText}`);
// 一律先拿文字,再嘗試轉 JSON(容忍錯誤 content-type、BOM、前後雜訊)
let raw = await resp.text();
const trimmed = raw.trim();
// 去掉可能的 XSSI 前綴(常見形態):
const xssiPrefixes = [")]}',", "while(1);", "for(;;);"];
for (const p of xssiPrefixes) if (trimmed.startsWith(p)) raw = trimmed.slice(p.length);
try { return JSON.parse(raw); }
catch { return raw; } // 真的不是 JSON 就回傳字串
} catch (err) {
// 後端失敗時的 mock
const mockMap = {
'/terms': 'data/terms.json',
'/terms/fear': 'data/terms_fear.json',
'/query/amygdala%20not%20emotion/studies': 'data/studies_amygdala_not_emotion.json',
};
if (mockMap[path]) {
const r = await fetch(mockMap[path]);
const t = await r.text();
try { return JSON.parse(t); } catch { return t; }
}
throw err;
}
}
renderStudies 換成這個(字串就嘗試 parse;失敗就把前 300 字印出來)jsfunction renderStudies(data){
const box = document.getElementById('studiesOut');
// 若是字串,盡力解析;失敗就顯示原文,方便除錯
if (typeof data === 'string') {
try { data = JSON.parse(data); }
catch (e) {
box.innerHTML = `<div class="text-sm text-red-600">Response is not JSON (showing first 300 chars):</div>
<pre class="text-xs whitespace-pre-wrap mt-2">${escapeHTML(data.slice(0,300))}</pre>`;
return;
}
}
// 兼容多種結構:純陣列或 {results: [...]}
const rows = Array.isArray(data) ? data
: (data && Array.isArray(data.results) ? data.results : []);
const total = (data && typeof data.count === 'number') ? data.count : rows.length;
if (!rows || rows.length === 0) {
box.innerHTML = '<div class="text-sm text-slate-500">No results.</div>';
return;
}
// 想要的欄位順序(可自行調整)
const cols = ['id','study_id','year','title','authors','journal'];
// 安全列印:把非基本型別 stringify
const td = (v) => {
if (v == null) return '';
if (typeof v === 'object') return escapeHTML(JSON.stringify(v));
return escapeHTML(String(v));
};
const head = cols.map(c => `<th class="px-3 py-2 text-left text-xs font-semibold bg-slate-50">${escapeHTML(c)}</th>`).join('');
const body = rows.map(r => `
<tr>
${cols.map(c => `<td class="px-3 py-2 border-t align-top text-sm">${td(r[c])}</td>`).join('')}
</tr>`).join('');
box.innerHTML = `
<div class="text-xs text-slate-500 mb-2">Count: ${escapeHTML(String(total))}</div>
<div class="overflow-x-auto">
<table class="min-w-full border rounded">
<thead><tr>${head}</tr></thead>
<tbody>${body}</tbody>
</table>
</div>`;
}
API_BASE 要用你現在能打到的主機:https://hpc.psy.ntu.edu.tw:5000。
重新整理請用 Ctrl/Cmd + Shift + R(清快取)。
若還是不顯示,Console 會出現我在 renderStudies 放的錯誤區塊;把那段紅字或前 300 字內容貼給我,我就能精準判斷(例如是否有奇怪的前綴、或回傳其實是 HTML 錯頁)。
可以!下面給你最小改動,直接貼上就能得到:
左側 Show all keywords 下方有 All / A–Z 篩選列(點 A 只顯示 A 開頭的關鍵字)
Studies 搜尋列改成 兩個輸入框 + AND/NOT 下拉選單 + Search
在你側欄這段裡,在 #btnAll 與 #allTerms 之間多放一個容器 #termsFilter:
html<div class="space-y-2">
<button id="btnAll" class="w-full rounded-lg bg-brand-500 hover:bg-brand-600 text-white px-4 py-2">
Show all keywords
</button>
<!-- NEW: A–Z filter bar -->
<div id="termsFilter" class="flex flex-wrap gap-1"></div>
<div id="allTerms"
class="h-56 overflow-auto rounded-lg bg-white/40 backdrop-blur border border-white/20 p-3 text-sm text-slate-800"></div>
</div>
把 Studies 區塊中的那行輸入+按鈕整段替換為以下網格:
html<div class="grid grid-cols-[1fr_auto_1fr_auto] gap-2 items-center">
<input id="studyInput1" class="rounded-lg border px-3 py-2" placeholder="e.g., amygdala" />
<select id="studyOp" class="rounded-lg border px-2 py-2 text-slate-700">
<option value="and">and</option>
<option value="not">not</option>
</select>
<input id="studyInput2" class="rounded-lg border px-3 py-2" placeholder="(optional) e.g., emotion" />
<button id="btnStudies" class="rounded-lg bg-brand-500 hover:bg-brand-600 text-white px-4 py-2">Search</button>
</div>
你原來的
id="studyInput"的那個輸入框就不用了(改為studyInput1、studyInput2、studyOp)。
在你的 <script> 中(其他函式保持不動),新增這些全域狀態與輔助函式,建議放在 badge/spinner 後面、History 之前或之後都行:
js// ---- A–Z filter state for /terms ----
let TERMS_CACHE = []; // 全部 terms
let TERMS_FILTER = 'all'; // 'all' 或 'a'..'z'
function renderAlphaBar(){
const letters = ['All', ...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'];
const html = letters.map(L => {
const key = L.toLowerCase();
const active = (TERMS_FILTER === key);
return `<button data-letter="${key}"
class="termflt rounded-md px-2 py-1 text-xs
${active ? 'bg-brand-500 text-white'
: 'bg-white/60 text-slate-700 hover:bg-white'}">
${L}
</button>`;
}).join('');
document.getElementById('termsFilter').innerHTML = html;
// 綁定點擊
document.querySelectorAll('.termflt').forEach(btn => {
btn.addEventListener('click', () => {
TERMS_FILTER = btn.dataset.letter;
renderAlphaBar(); // 重畫 active 樣式
renderTermsFiltered(); // 依條件重畫 terms
});
});
}
function renderTermsFiltered(){
const box = document.getElementById('allTerms');
const list = TERMS_CACHE || [];
if (list.length === 0){
box.innerHTML = '<div class="text-sm text-slate-600">(no terms)</div>';
return;
}
const filtered = (TERMS_FILTER === 'all')
? list
: list.filter(t => String(t).toLowerCase().startsWith(TERMS_FILTER));
box.innerHTML = `<div class="flex flex-wrap">${filtered.map(badge).join('')}</div>`;
}
把你原本的 renderAllTerms 改成以下版本(它會把結果存進 TERMS_CACHE,然後畫出 A–Z 列與 chips):
jsfunction renderAllTerms(data){
// 後端回傳可能是 {terms:[...]} 或純陣列
const list = Array.isArray(data) ? data
: (data && Array.isArray(data.terms) ? data.terms : []);
TERMS_CACHE = list || [];
renderAlphaBar();
renderTermsFiltered();
}
把你 #btnStudies 的事件處理器整段替換為:
jsdocument.getElementById('btnStudies')?.addEventListener('click', async () => {
const q1 = document.getElementById('studyInput1').value.trim();
const q2 = document.getElementById('studyInput2').value.trim();
const op = document.getElementById('studyOp').value; // 'and' | 'not'
if (!q1) {
document.getElementById('studiesOut').innerHTML =
'<div class="text-sm text-slate-500">Please enter the first term.</div>';
return;
}
// 組合查詢字串
const q = q2 ? `${q1} ${op} ${q2}` : q1;
const path = `/query/${encodeURIComponent(q)}/studies`;
document.getElementById('urlStudies').innerHTML =
`GET <a class="text-brand-600 underline" target="_blank" href="${API_BASE}${path}">${API_BASE}${path}</a>`;
document.getElementById('studiesOut').innerHTML = spinner('Searching studies…');
try {
const data = await fetchJSON(path);
renderStudies(data); // 你先前已經修成支援 {count, results:[...]} 的版本
pushHistory('studies', q); // 也把完整 q 記進 history
} catch (e) {
document.getElementById('studiesOut').innerHTML =
`<div class="text-red-600 text-sm">${escapeHTML(e.message)}</div>`;
}
});
/terms 先按一次「Show all keywords」才會把 TERMS_CACHE 填好,之後切 A–Z 就會即時過濾。
你的 renderRelated 與 renderStudies 之前已改成表格版且兼容新結構,保持原樣即可。
若要讓「相關詞表格」中的 term 可點擊回填到「associated terms」輸入框再查,告訴我要不要加,我可以幫你把每列變成 clickable。
完成以上四步,存檔、Ctrl/Cmd + Shift + R 強制重整,就會看到:
A–Z 篩選列運作,All 顯示全部,A/B/C 只顯示對應開頭;
Studies 支援「amygdala and faces」、「amygdala not emotion」這類組合查詢。
可以,最簡單是把 pushHistory 改成「先去重、再插入最新一筆」。這樣你不需要改其它地方(包含從 History 點擊會觸發的 .click() 邏輯),因為每次新增同一個 (type, q) 時,舊的會被移除,只保留最新一筆在最上方。
把你的 pushHistory 整段換成下面這個即可:
jsfunction pushHistory(type, q){
const now = new Date().toISOString().slice(0,19).replace('T',' ');
const norm = (s) => String(s ?? '').trim().toLowerCase();
const key = `${norm(type)}::${norm(q)}`;
// 讀取舊資料並移除同 key 的舊紀錄
let arr = JSON.parse(localStorage.getItem(HISTORY_KEY) || '[]');
arr = arr.filter(it => `${norm(it.type)}::${norm(it.q)}` !== key);
// 插入最新版在最前面
arr.unshift({ type, q, at: now });
// 保留最多 50 筆
localStorage.setItem(HISTORY_KEY, JSON.stringify(arr.slice(0,50)));
// 重新渲染
renderHistory();
}
這樣的效果:
從輸入框發起搜尋或從 History「重播」搜尋,都只會留下一筆該組合的記錄(最新時間)。
大小寫、前後空白不會造成重複(已經標準化比較)。
其餘程式不用動;renderHistory() 會照常更新列表。
非常好 👍
你要把「studies」那塊改回成普通單一搜尋框,並顯示提示文字
“Use and, not for logical search. e.g. amygdala not emotion”
同時不影響後端 /query/<q>/studies 呼叫邏輯。
這樣就能自由輸入多個 and / not 條件(像 amygdala and faces not emotion)。
把 HTML 裡「Search for studies」區塊的這一段:
html<div class="grid grid-cols-[1fr_auto_1fr_auto] gap-2 items-center">
<input id="studyInput1" class="rounded-lg border px-3 py-2" placeholder="e.g., amygdala" />
<select id="studyOp" class="rounded-lg border px-2 py-2 text-slate-700">
<option value="and">and</option>
<option value="not">not</option>
</select>
<input id="studyInput2" class="rounded-lg border px-3 py-2" placeholder="(optional) e.g., emotion" />
<button id="btnStudies" class="rounded-lg bg-brand-500 hover:bg-brand-600 text-white px-4 py-2">Search</button>
</div>
整段 換成下面這段:
html<div class="flex gap-2">
<input id="studyInput"
class="flex-1 rounded-lg border px-3 py-2"
placeholder="Use and, not for logical search. e.g. amygdala not emotion" />
<button id="btnStudies"
class="rounded-lg bg-brand-500 hover:bg-brand-600 text-white px-4 py-2">
Search
</button>
</div>
找到這段(你之前加了兩個輸入框的版本):
jsdocument.getElementById('btnStudies')?.addEventListener('click', async () => {
const q1 = document.getElementById('studyInput1').value.trim();
const q2 = document.getElementById('studyInput2').value.trim();
const op = document.getElementById('studyOp').value; // 'and' | 'not'
...
});
整段替換為這個:
jsdocument.getElementById('btnStudies')?.addEventListener('click', async () => {
const q = document.getElementById('studyInput').value.trim();
if (!q) {
document.getElementById('studiesOut').innerHTML =
'<div class="text-sm text-slate-500">Please enter a query.</div>';
return;
}
const path = `/query/${encodeURIComponent(q)}/studies`;
document.getElementById('urlStudies').innerHTML =
`GET <a class="text-brand-600 underline" target="_blank" href="${API_BASE}${path}">${API_BASE}${path}</a>`;
document.getElementById('studiesOut').innerHTML = spinner('Searching studies…');
try {
const data = await fetchJSON(path);
renderStudies(data);
pushHistory('studies', q);
} catch (e) {
document.getElementById('studiesOut').innerHTML =
`<div class="text-red-600 text-sm">${escapeHTML(e.message)}</div>`;
}
});
這樣就能:
在單一輸入框中輸入多重條件(不限兩個);
仍保留現有邏輯搜尋;
顯示符合語意的提示文字。
非常好 👍 這份 README 我幫你寫成正式、乾淨、適合放在 GitHub 的版本,完整解釋每個功能區塊(對應你目前的 frontend 行為與設計)。
你可以直接存成 README.md。
This project provides an interactive AJAX-based web interface for exploring neuroscience meta-analytic data from
https://hpc.psy.ntu.edu.tw:5000.
The interface is built with Tailwind CSS and vanilla JavaScript (AJAX), and allows users to query the Neurosynth API endpoints for:
available terms,
associated terms with co-occurrence statistics,
and study-level metadata filtered via logical search operators (and, not).
Purpose:
Displays all possible keywords available in the Neurosynth database.
Features:
Show all keywords:
Fetches all available terms from the /terms endpoint.
A–Z filter bar:
Interactive alphabetical filter (All, A, B, C, …).
Clicking a letter filters the terms by their starting letter.
Scrollable view:
Results are displayed as keyword badges in a scrollable panel with translucent background.
Endpoint used:
bashGET https://hpc.psy.ntu.edu.tw:5000/terms
Purpose:
Given a keyword, shows other terms that commonly co-occur with it,
along with their statistical association measures.
Features:
Dynamic search input:
Input any keyword (e.g., amygdala) and press Search.
Co-occurrence data visualization:
Results are displayed as a sortable table, including:
term — the related word
co_count — co-occurrence frequency
jaccard — Jaccard similarity index
AJAX-based dynamic updates:
Results load instantly without page refresh.
Endpoint used:
bashGET https://hpc.psy.ntu.edu.tw:5000/terms/<term>
Example:
https://hpc.psy.ntu.edu.tw:5000/terms/amygdala
Purpose:
Performs a logical keyword search over the Neurosynth studies database and displays
metadata (title, authors, year, journal, etc.) as a table.
Features:
Single flexible input field:
Users can freely enter multi-term queries using logical operators:
nginxamygdala and fear not emotion
Supports unlimited conditions:
There is no restriction on the number of and or not connectors.
Results table:
Displays study metadata including:
id, study_id
year
title
authors
journal
Scrollable table layout:
Automatically adjusts to large datasets with smooth scrolling.
Endpoint used:
bashGET https://hpc.psy.ntu.edu.tw:5000/query/<query>/studies
Examples:
https://hpc.psy.ntu.edu.tw:5000/query/amygdala/studies
https://hpc.psy.ntu.edu.tw:5000/query/amygdala%20not%20emotion/studies
Purpose:
Keeps a record of all previously performed searches and allows quick re-execution.
Features:
Search log:
Every executed search (for terms or studies) is saved with timestamp and query type.
Click-to-replay:
Clicking a past record automatically re-triggers that search using the same query.
Auto-deduplication:
Each unique search (type + query) is stored only once; the most recent replaces older duplicates.
Clear button:
Users can clear the history log manually.
Storage:
Saved locally using localStorage as JSON (neurosearch_history_v1).
| Component | Description |
|---|---|
| Frontend | HTML5 + Vanilla JS (ES6) |
| Styling | Tailwind CSS via CDN |
| Async Logic | Fetch API with async/await |
| Backend API | Neurosynth API @ NTU |
| Local Cache | Browser LocalStorage for history tracking |
| Endpoint | Description |
|---|---|
/terms | List all available keywords |
/terms/<term> | List related / co-occurring terms |
/query/<query>/studies | Search for studies by logical keywords |
pgsql ┌────────────────────────────┬───────────────────────────────────────┐
│ Sidebar │ Main Panel │
│────────────────────────────│───────────────────────────────────────│
│ [Show all keywords] │ [Associated Terms Search] │
│ [A–Z filter] │ - input box + Search button │
│ [History log] │ - co-occurrence result table │
│ │ [Logical Study Search] │
│ │ - flexible logical input │
│ │ - metadata result table │
└────────────────────────────┴───────────────────────────────────────┘
Clone or download this repository.
Open the project folder in VS Code (or any IDE).
Run with Live Server (right-click → Open with Live Server).
The default homepage index.html should load the interface.
Start exploring terms and studies!
This frontend is for academic and demonstration purposes at
National Taiwan University, Department of Psychology.
Developed by Annie Fu (傅學恩), integrating Neurosynth API for moral and neuroscience research visualization.
是否要我幫你補上 README 裡的截圖範例(自動生成帶說明框的 mock-up 圖)?
我可以根據目前的畫面生成一張簡潔的介面示意圖放在最上方。